diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index b9beedb56aa..c19730e8fbb 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -40,6 +40,8 @@ export interface ToolPartState { argsPreview?: string /** Full args (pretty JSON) for the expanded view — `args_text` (redacted) or stringified `args`. */ argsText?: string + /** Structured args from `tool.complete` (always sent) — the per-tool renderers read these. */ + args?: Record /** Tool wall-clock seconds (gateway `duration_s`), shown dim in the header. */ duration?: number /** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */ @@ -653,11 +655,15 @@ export function createSessionStore() { if (duration !== undefined) part.duration = duration if (omittedNote) part.omittedNote = omittedNote // argsPreview (from tool.start `context`) is intentionally NOT overwritten. - if (!part.argsText && argsObj && typeof argsObj === 'object') { - try { - part.argsText = JSON.stringify(argsObj, null, 2) - } catch { - /* unstringifiable args — leave unset */ + if (argsObj && typeof argsObj === 'object') { + // structured args feed the per-tool renderers (labeled fields, bash command). + if (!Array.isArray(argsObj)) part.args = argsObj as Record + if (!part.argsText) { + try { + part.argsText = JSON.stringify(argsObj, null, 2) + } catch { + /* unstringifiable args — leave unset */ + } } } }) diff --git a/ui-opentui/src/test/lib/render.ts b/ui-opentui/src/test/lib/render.ts index 26e960803f3..c0c305eccc0 100644 --- a/ui-opentui/src/test/lib/render.ts +++ b/ui-opentui/src/test/lib/render.ts @@ -41,6 +41,8 @@ export interface RenderProbe { readonly frame: () => string readonly waitForFrame: (predicate: (frame: string) => boolean) => Promise readonly resize: (width: number, height: number) => void + /** Left-click at screen cell (x, y) via the mock mouse, then settle a pass. */ + readonly click: (x: number, y: number) => Promise readonly destroy: () => void } @@ -68,6 +70,11 @@ export async function renderProbe( frame: () => setup.captureCharFrame(), waitForFrame: predicate => setup.waitForFrame(predicate), resize: (width, height) => setup.resize(width, height), + click: async (x, y) => { + await setup.mockMouse.click(x, y) + await setup.renderOnce() + await setup.flush() + }, destroy: () => setup.renderer.destroy?.() } } diff --git a/ui-opentui/src/test/store.test.ts b/ui-opentui/src/test/store.test.ts index ee25e4ae0ea..10e33f692eb 100644 --- a/ui-opentui/src/test/store.test.ts +++ b/ui-opentui/src/test/store.test.ts @@ -152,6 +152,20 @@ describe('session store — ordered parts (Phase 2b)', () => { expect(tool.lineCount).toBe(2) }) + test('tool.complete captures structured args into part.args (renderer registry feed)', () => { + const store = createSessionStore() + store.apply({ type: 'message.start' }) + store.apply({ type: 'tool.start', payload: { tool_id: 'a', name: 'mcp_lookup' } }) + store.apply({ + type: 'tool.complete', + payload: { tool_id: 'a', args: { query: 'hermes', options: { depth: 2 } }, result_text: 'ok' } + }) + const tool = store.state.messages.at(-1)!.parts![0]! + if (tool.type !== 'tool') throw new Error('expected a tool part') + expect(tool.args).toEqual({ query: 'hermes', options: { depth: 2 } }) // full structured dict + expect(tool.argsText).toContain('"query"') // stringified fallback still kept + }) + test('setCatalog maps the loose startup.catalog response defensively (item 9)', () => { const store = createSessionStore() store.setCatalog({ diff --git a/ui-opentui/src/test/tools.test.tsx b/ui-opentui/src/test/tools.test.tsx new file mode 100644 index 00000000000..c106a4a7dbd --- /dev/null +++ b/ui-opentui/src/test/tools.test.tsx @@ -0,0 +1,118 @@ +/** + * Tool renderer tests (Epic 2.2). Headless frames through the real App tree: + * the registry's default renderer turns args into LABELED FIELDS — the + * acceptance gate asserts NO raw JSON syntax (`{"` / `":`) ever reaches the + * frame for tool parts, collapsed or expanded — and delegate_task carries the + * Ink-parity "(/agents to monitor)" hint. Expansion goes through the REAL + * mouse path: mockMouse clicks the header row (found by scanning the frame). + */ +import { describe, expect, test } from 'vitest' + +import { createSessionStore } from '../logic/store.ts' +import { App } from '../view/App.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { renderProbe, type RenderProbe } from './lib/render.ts' + +type Store = ReturnType + +/** Seed a settled assistant turn containing exactly the given tool call. */ +function seedTool(store: Store, start: Record, complete: Record) { + store.apply({ type: 'gateway.ready' }) + store.apply({ type: 'message.start' }) + store.apply({ type: 'tool.start', payload: start }) + store.apply({ type: 'tool.complete', payload: complete }) + store.apply({ type: 'message.complete' }) +} + +async function mountApp(store: Store, width = 80, height = 24): Promise { + return renderProbe( + () => ( + store.state.theme}> + + + ), + { width, height } + ) +} + +/** Click the tool header row (the line containing `name`) to expand/collapse. */ +async function clickHeader(probe: RenderProbe, name: string): Promise { + const frame = await probe.waitForFrame(f => f.includes(name)) + const rows = frame.split('\n') + const y = rows.findIndex(line => line.includes(name)) + expect(y).toBeGreaterThanOrEqual(0) + const x = (rows[y] ?? '').indexOf(name) + await probe.click(x, y) +} + +describe('tool renderer registry — labeled-args default (Epic 2.2)', () => { + test('an unmapped MCP-ish tool with nested args renders labeled fields, never raw JSON', async () => { + const store = createSessionStore() + seedTool( + store, + { tool_id: 'm1', name: 'mcp_lookup' }, + { + tool_id: 'm1', + name: 'mcp_lookup', + args: { + query: 'hermes agent', + options: { depth: 2, mode: 'fast', cache: true }, + limit: 5 + }, + duration_s: 0.4, + result_text: 'one result found' + } + ) + + const probe = await mountApp(store) + try { + // collapsed: header only, and already no JSON syntax anywhere + const collapsed = await probe.waitForFrame(f => f.includes('mcp_lookup')) + expect(collapsed).not.toContain('{"') + expect(collapsed).not.toContain('":') + + await clickHeader(probe, 'mcp_lookup') + const expanded = await probe.waitForFrame(f => f.includes('query')) + // labeled key → value rows (string verbatim, number via String) + expect(expanded).toContain('query') + expect(expanded).toContain('hermes agent') + expect(expanded).toContain('limit') + expect(expanded).toContain('5') + // nested object summarized, not dumped + expect(expanded).toContain('options') + expect(expanded).toContain('(3 fields)') + // the output body still renders (envelope-stripped store text) + expect(expanded).toContain('one result found') + // THE acceptance gate: no raw JSON syntax in the tool render + expect(expanded).not.toContain('{"') + expect(expanded).not.toContain('":') + expect(expanded).not.toContain('depth') // nested internals stay summarized + } finally { + probe.destroy() + } + }) + + test('delegate_task gets the default renderer plus the muted "(/agents to monitor)" hint', async () => { + const store = createSessionStore() + seedTool( + store, + { tool_id: 'd1', name: 'delegate_task', context: 'research opentui' }, + { + tool_id: 'd1', + name: 'delegate_task', + args: { goal: 'research opentui', model: 'fast' }, + result_text: 'spawned' + } + ) + + const probe = await mountApp(store) + try { + const frame = await probe.waitForFrame(f => f.includes('(/agents to monitor)')) + expect(frame).toContain('delegate_task') + expect(frame).toContain('research opentui') // primary-arg preview still leads + expect(frame).not.toContain('{"') // hint or not — still no raw JSON + } finally { + probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/view/toolPart.tsx b/ui-opentui/src/view/toolPart.tsx index b5b79505ac8..1885c9f38e7 100644 --- a/ui-opentui/src/view/toolPart.tsx +++ b/ui-opentui/src/view/toolPart.tsx @@ -1,31 +1,30 @@ /** * ToolPart — one tool call, rendered COLLAPSED by default with a clear expand - * affordance (items 2 + 7). The header shows the tool's PRIMARY ARG inline so - * you can read what it did without expanding (item 2 — "I don't see tool args"): + * affordance. This is the SHARED SHELL: the header (glyph + name + subtitle + + * duration + line count + optional hint) and the expand/collapse mechanics — + * what's INSIDE varies per tool and is dispatched through the tool renderer + * registry (`view/tools/registry.tsx`, Epic 2.2): * * ▶ terminal ls -la src · 0.3s (12 lines) ← collapsed (default) * ▼ terminal ls -la src · 0.3s ← expanded header - * │ args { … } ← full args (when present) - * │ output … ← envelope-stripped body - * │ … omitted 5 lines / 234 chars ← tidy note (no raw label) + * │ ← labeled fields / output / … * - * `▶`/`▼` marks expandable tools; clicking the header toggles it. Running tools - * show `name …`. `resultText`/`omittedNote` are already cleaned by the store. - * Fully themed (no hardcoded styles); decorative glyphs are selectable={false}. + * `▶`/`▼` marks expandable tools; clicking the header toggles it (wrapped in + * useScrollAnchor so expanding never yanks the viewport). Running tools show + * `name …`. The header row is chrome (selectable=false) — a free-form drag + * copies only the expanded body content. Fully themed (no hardcoded styles). */ import { type ToolPartState } from '../logic/store.ts' import { useDimensions } from './dimensions.tsx' -import { createMemo, createSignal, For, Show } from 'solid-js' +import { createSignal, Show } from 'solid-js' -import { collapseToolOutput, truncate } from '../logic/toolOutput.ts' +import { truncate } from '../logic/toolOutput.ts' import { useScrollAnchor } from './scrollAnchor.tsx' import { useTheme } from './theme.tsx' +import { resultLines } from './tools/defaultTool.tsx' +import { rendererFor } from './tools/registry.tsx' const GUTTER = 2 -/** Max output lines shown when expanded (a sane cap to avoid huge renders). */ -const EXPANDED_MAX = 200 -/** Max args lines shown when expanded. */ -const ARGS_MAX = 16 function fmtDuration(s: number): string { if (s < 10) return `${s.toFixed(1)}s` @@ -42,47 +41,16 @@ export function ToolPart(props: { part: ToolPartState }) { const [expanded, setExpanded] = createSignal(false) const toggle = () => anchor(() => setExpanded(e => !e)) + // Per-tool renderer (re-dispatches if the name settles on tool.complete). + const renderer = () => rendererFor(props.part.name) const bodyWidth = () => Math.max(20, dims().width - GUTTER - 4) - const result = () => (props.part.resultText ?? '').replace(/\s+$/, '') - const lines = () => (result() ? result().split('\n') : []) + const lines = () => resultLines(props.part) const running = () => props.part.state === 'running' - const hasOutput = () => lines().length > 0 - // Parse the args JSON into top-level key→value entries for a tidy key:value - // render (no brace noise). Falls back to raw lines when it isn't an object. - const argsObj = createMemo | undefined>(() => { - const t = props.part.argsText - if (!t) return undefined - try { - const o: unknown = JSON.parse(t) - return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record) : undefined - } catch { - return undefined - } - }) - const argLine = (k: string, v: unknown) => - `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`.replace(/\s+/g, ' ') - const argEntries = createMemo(() => Object.entries(argsObj() ?? {})) - // Hide the args block when it adds nothing over the header: a single field - // whose value is already the primary-arg preview (item 2 judge nit — terminal's - // `command` is redundant). Show it for multi-field tools (edits, reads w/ range). - const showArgs = createMemo(() => { - const e = argEntries() - if (argsObj() === undefined) return !!props.part.argsText // unparsed → show raw - if (e.length === 0) return false - const only = e.length === 1 ? e[0] : undefined - if (only) { - const v = only[1] - const vs = (typeof v === 'string' ? v : JSON.stringify(v)).trim() - return vs !== (props.part.argsPreview ?? '').trim() - } - return true - }) - // Expandable when there's a body to reveal beyond the header (output or args). - const collapsible = () => !running() && (lines().length > 1 || showArgs()) - // Header subtitle: the primary-arg preview (item 2), else explicit summary, else first line. - const subtitle = () => - props.part.error ? `✗ ${props.part.error}` : props.part.argsPreview || props.part.summary || lines()[0] || '' - const body = createMemo(() => collapseToolOutput(result(), EXPANDED_MAX, bodyWidth() - 2)) + // Expandable when the renderer says there's a body to reveal beyond the header. + const collapsible = () => !running() && renderer().expandable(props.part) + // Header subtitle: errors win; otherwise the renderer's collapsed summary. + const subtitle = () => (props.part.error ? `✗ ${props.part.error}` : renderer().subtitle(props.part)) + const hint = () => renderer().hint?.(props.part) const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡') // accent glyph MARKS the tool (draws the eye); the rest is muted so tools read @@ -92,9 +60,9 @@ export function ToolPart(props: { part: ToolPartState }) { return ( // Spacing between parts is owned by the parts column (gap), not per-part - // margins — so a tool appearing mid-stream doesn't shift the layout (item 5). + // margins — so a tool appearing mid-stream doesn't shift the layout. - {/* header — clickable to toggle when there's expandable output/args */} + {/* header — clickable to toggle when there's an expandable body */} collapsible() && toggle()}> @@ -102,10 +70,10 @@ export function ToolPart(props: { part: ToolPartState }) { - {/* the whole header row is a collapsed SUMMARY (tool name + args-preview - + duration + "(N lines)") — chrome, not the copyable body — so a - free-form drag over a tool yields only the expanded output/args - content, never the header label (item 4). */} + {/* the whole header row is a collapsed SUMMARY (tool name + subtitle + + duration + "(N lines)") — chrome, not the copyable body — so a + free-form drag over a tool yields only the expanded body content, + never the header label. */} {props.part.name} @@ -116,6 +84,11 @@ export function ToolPart(props: { part: ToolPartState }) { {` ${truncate(subtitle(), subWidth())}`} + + {/* per-tool muted hint (e.g. delegate_task's "(/agents to monitor)") — + shown while running too, Ink parity. */} + {` ${hint() ?? ''}`} + {` · ${fmtDuration(props.part.duration ?? 0)}`} @@ -126,77 +99,19 @@ export function ToolPart(props: { part: ToolPartState }) { - {/* expanded body — args block (when present) then output block, inside a - single left-bordered column (a `│` rule, not a bg fill — opencode's - BlockTool style; also renders faithfully and reads cleaner). */} + {/* expanded body — the per-tool renderer's Body, inside a single + left-bordered column (a `│` rule, not a bg fill — opencode's BlockTool + style; also renders faithfully and reads cleaner). */} - - - {/* section label — chrome, not content (item 4) */} - - args - - {/* parsed key: value lines (tidy), or raw argsText when unparseable */} - - {line => ( - - {truncate(line, bodyWidth() - 2)} - - )} - - } - > - - {([k, v]) => ( - - {truncate(argLine(k, v), bodyWidth() - 2)} - - )} - - ARGS_MAX}> - {/* overflow annotation — chrome, not content (item 4) */} - - {`… +${argEntries().length - ARGS_MAX} more`} - - - - - - {/* section label — chrome, not content (item 4) */} - - output - - - {/* output body lines are the copyable content → themed selection bar - (preserves fg; same token as message text) (item: theme highlight). */} - - {line => ( - - {line} - - )} - - {/* truncation annotations — chrome (the "… omitted N" / "… +N more - lines" notes are not part of the real output body) (item 4). */} - - - {`… omitted ${props.part.omittedNote}`} - - - 0 && !props.part.omittedNote}> - - {`… +${body().hiddenLines} more lines`} - - - + {(() => { + const Body = renderer().Body + return + })()} diff --git a/ui-opentui/src/view/tools/defaultTool.tsx b/ui-opentui/src/view/tools/defaultTool.tsx new file mode 100644 index 00000000000..baba81bbdb9 --- /dev/null +++ b/ui-opentui/src/view/tools/defaultTool.tsx @@ -0,0 +1,159 @@ +/** + * DefaultTool — the fallback renderer for every unmapped tool, incl. MCP tools + * (Epic 2.2: kill the raw-JSON args path). Expanded args render as LABELED + * FIELDS (key → value rows), never a JSON dump: + * - strings verbatim, flattened to one line + truncated to the frame width + * - numbers/booleans via String() + * - arrays of primitives joined ("a, b, c"); anything nested summarized as + * `(N fields)` / `(N items)` (opencode's primitive-only `input()` idea) + * A single field whose value already equals the header's primary-arg preview is + * hidden (it adds nothing over the header). The output body keeps the store's + * envelope-stripped text, capped to EXPANDED_MAX with the honest omitted / + * "+N more lines" notes. `ToolOutputBlock` is shared with the per-tool + * renderers (bash, …). Fully themed; labels/notes are chrome (selectable=false). + */ +import { createMemo, For, Show } from 'solid-js' + +import type { ToolPartState } from '../../logic/store.ts' +import { collapseToolOutput, truncate } from '../../logic/toolOutput.ts' +import { useTheme } from '../theme.tsx' +import type { ToolBodyProps, ToolRenderer } from './registry.tsx' + +/** Max output lines shown when expanded (a sane cap to avoid huge renders). */ +export const EXPANDED_MAX = 200 +/** Max labeled arg fields shown when expanded. */ +const FIELDS_MAX = 16 + +/** The tool's structured args: `part.args` (tool.complete) or parsed argsText. */ +export function structuredArgs(part: ToolPartState): Record | undefined { + if (part.args) return part.args + if (!part.argsText) return undefined + try { + const o: unknown = JSON.parse(part.argsText) + return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record) : undefined + } catch { + return undefined + } +} + +function isPrimitive(v: unknown): v is string | number | boolean { + return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' +} + +/** One-line display value for an arg: primitives verbatim, nesting summarized. */ +function fieldValue(v: unknown): string { + if (typeof v === 'string') return v.replace(/\s+/g, ' ').trim() + if (typeof v === 'number' || typeof v === 'boolean') return String(v) + if (v === null || v === undefined) return '∅' + if (Array.isArray(v)) { + if (v.length > 0 && v.every(isPrimitive)) return v.map(String).join(', ') + return `(${v.length} item${v.length === 1 ? '' : 's'})` + } + const n = Object.keys(v).length + return `(${n} field${n === 1 ? '' : 's'})` +} + +/** Labeled key→value rows for the expanded args (NEVER raw JSON). */ +export function argFields(part: ToolPartState): Array<[string, string]> { + const obj = structuredArgs(part) + if (!obj) return [] + const entries = Object.entries(obj).map(([k, v]): [string, string] => [k, fieldValue(v)]) + // A single field whose value is already the header's primary-arg preview adds + // nothing over the header (e.g. terminal's `command`) — hide it (kept from + // the pre-registry render's redundancy rule). + const only = entries.length === 1 ? entries[0] : undefined + if (only && only[1] === (part.argsPreview ?? '').trim()) return [] + return entries +} + +/** The settled output body, trailing-whitespace-trimmed, split to lines. */ +export function resultLines(part: ToolPartState): string[] { + const r = (part.resultText ?? '').replace(/\s+$/, '') + return r ? r.split('\n') : [] +} + +/** Collapsed subtitle: primary-arg preview, else summary, else the first output line. */ +export function defaultSubtitle(part: ToolPartState): string { + return part.argsPreview || part.summary || resultLines(part)[0] || '' +} + +/** + * The output section of an expanded tool body — shared by the default and the + * per-tool renderers. Caps to EXPANDED_MAX and renders the honest truncation + * notes (`omittedNote` from the gateway cap; "+N more lines" from ours). + */ +export function ToolOutputBlock(props: { part: ToolPartState; width: number; label?: boolean }) { + const theme = useTheme() + const result = () => (props.part.resultText ?? '').replace(/\s+$/, '') + const body = createMemo(() => collapseToolOutput(result(), EXPANDED_MAX, props.width)) + return ( + + {/* section label — chrome, not content */} + + + output + + + {/* output body lines are the copyable content → themed selection bar */} + + {line => ( + + {line} + + )} + + {/* truncation annotations — chrome (not part of the real output body) */} + + + {`… omitted ${props.part.omittedNote}`} + + + 0 && !props.part.omittedNote}> + + {`… +${body().hiddenLines} more lines`} + + + + ) +} + +/** Expanded body: labeled arg fields, then the (capped) output block. */ +export function DefaultToolBody(props: ToolBodyProps) { + const theme = useTheme() + const fields = createMemo(() => argFields(props.part)) + return ( + + 0}> + + {([key, value]) => ( + + {key} + + {` ${truncate(value, Math.max(1, props.width - key.length - 2))}`} + + + )} + + FIELDS_MAX}> + {/* overflow annotation — chrome, not content */} + + {`… +${fields().length - FIELDS_MAX} more`} + + + + 0} /> + + ) +} + +export const defaultRenderer: ToolRenderer = { + Body: DefaultToolBody, + // Expandable when there's a body beyond the header: multi-line output, labeled + // arg fields, or a single output line the subtitle doesn't already show + // (argsPreview/summary win the subtitle, hiding that line when collapsed). + expandable: part => + resultLines(part).length > 1 || + argFields(part).length > 0 || + (resultLines(part).length === 1 && Boolean(part.argsPreview || part.summary)), + subtitle: defaultSubtitle +} diff --git a/ui-opentui/src/view/tools/registry.tsx b/ui-opentui/src/view/tools/registry.tsx new file mode 100644 index 00000000000..1c56df3e27c --- /dev/null +++ b/ui-opentui/src/view/tools/registry.tsx @@ -0,0 +1,49 @@ +/** + * Tool renderer registry (Epic 2.2) — maps a tool NAME to its renderer. The + * shared shell (header glyph, expand toggle + scroll anchoring, the left-border + * body frame) stays in `view/toolPart.tsx` so every tool keeps the house rules + * (useScrollAnchor, themed chrome) for free; a renderer only supplies what + * varies per tool: + * - `subtitle` — the collapsed one-line summary shown after the tool name + * - `hint` — an extra muted header note (e.g. delegate_task's monitor tip) + * - `expandable` — whether there's a body worth expanding beyond the header + * - `Body` — the expanded body (labeled arg fields / output / diff) + * + * Unmapped tools (incl. MCP tools) fall back to the labeled-fields default + * renderer — NEVER a raw JSON dump. To add a per-tool renderer (e.g. + * `fileTool.tsx` for read/write/edit path+diff — Epic 2.3): export a + * `ToolRenderer` from a sibling module and add its tool names to `TOOLS`. + */ +import type { Component } from 'solid-js' + +import type { ToolPartState } from '../../logic/store.ts' +import { defaultRenderer } from './defaultTool.tsx' + +/** Props every tool Body receives: the part + usable content columns. */ +export interface ToolBodyProps { + part: ToolPartState + /** Width (columns) available for body lines inside the bordered frame. */ + width: number +} + +export interface ToolRenderer { + /** Collapsed one-line subtitle (verbatim command, primary arg, …). */ + subtitle: (part: ToolPartState) => string + /** Optional muted header note (chrome) — e.g. delegate_task's "(/agents to monitor)". */ + hint?: (part: ToolPartState) => string + /** Whether the part has expandable content beyond the header (when settled). */ + expandable: (part: ToolPartState) => boolean + /** The expanded body, rendered inside the shared left-bordered frame. */ + Body: Component +} + +const TOOLS: Record = { + // delegate_task: default labeled fields + the Ink-parity monitor hint + // (ui-tui/src/components/thinking.tsx — "(/agents to monitor)"). + delegate_task: { ...defaultRenderer, hint: () => '(/agents to monitor)' } +} + +/** Resolve the renderer for a tool name (default = labeled-fields fallback). */ +export function rendererFor(name: string): ToolRenderer { + return TOOLS[name] ?? defaultRenderer +}