From 0f92a3cf63d41a787c269831b5715ec8317b2a56 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 16:18:08 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20bash=20tool=20renderer=20?= =?UTF-8?q?=E2=80=94=20command=20+=20full=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-opentui/src/logic/store.ts | 21 +++- ui-opentui/src/test/store.test.ts | 34 +++++++ ui-opentui/src/test/tools.test.tsx | 130 +++++++++++++++++++++++-- ui-opentui/src/view/tools/bashTool.tsx | 74 ++++++++++++++ ui-opentui/src/view/tools/registry.tsx | 7 +- 5 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 ui-opentui/src/view/tools/bashTool.tsx diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index c19730e8fbb..09d083c6477 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -219,6 +219,18 @@ function readOptNum(payload: { readonly [k: string]: unknown }, key: string): nu return typeof v === 'number' ? v : undefined } +/** Render a raw tool `result` for display: strings as-is, anything else pretty + * JSON — both then go through the same envelope-strip pipeline as result_text. */ +function stringifyResult(v: unknown): string | undefined { + if (typeof v === 'string') return v + if (v === null || v === undefined) return undefined + try { + return JSON.stringify(v, null, 2) + } catch { + return undefined + } +} + /** * Fold a `session.info` / `session.create.info` payload into a partial SessionInfo. * The loose wire JSON is decoded ONCE via `SessionInfoPatchSchema` (decode-at- @@ -629,9 +641,14 @@ export function createSessionStore() { const name = readStr(event.payload, 'name') const error = readStr(event.payload, 'error') const summary = readStr(event.payload, 'summary') - // Peel the gateway's "[showing verbose tail; omitted …]" label (item 2) before + // `result_text` is verbose-gated, but the raw `result` is ALWAYS sent — + // when the verbose text is absent, derive the display body from `result` + // (so e.g. bash output still renders in non-verbose sessions). Then peel + // the gateway's "[showing verbose tail; omitted …]" label (item 2) before // envelope-stripping, so the body is clean and the note renders tidily. - const { body: rawBody, omittedNote } = stripOmittedNote(readStr(event.payload, 'result_text') ?? summary ?? '') + const { body: rawBody, omittedNote } = stripOmittedNote( + readStr(event.payload, 'result_text') ?? stringifyResult(event.payload['result']) ?? summary ?? '' + ) const resultText = stripToolEnvelope(rawBody) const lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0 // `args` (full dict) is always sent; stringify as the expanded-view args diff --git a/ui-opentui/src/test/store.test.ts b/ui-opentui/src/test/store.test.ts index 10e33f692eb..454d2753276 100644 --- a/ui-opentui/src/test/store.test.ts +++ b/ui-opentui/src/test/store.test.ts @@ -166,6 +166,40 @@ describe('session store — ordered parts (Phase 2b)', () => { expect(tool.argsText).toContain('"query"') // stringified fallback still kept }) + test('derives resultText from the raw `result` when result_text is absent (non-verbose sessions)', () => { + const store = createSessionStore() + store.apply({ type: 'message.start' }) + store.apply({ type: 'tool.start', payload: { tool_id: 'nv', name: 'terminal' } }) + // non-verbose: no result_text — only the raw envelope-string `result` + store.apply({ + type: 'tool.complete', + payload: { tool_id: 'nv', result: '{"output":"hi there\\nline two","exit_code":0,"error":null}' } + }) + const tool = store.state.messages.at(-1)!.parts![0]! + if (tool.type !== 'tool') throw new Error('expected a tool part') + expect(tool.resultText).toBe('hi there\nline two') // envelope stripped, same pipeline + expect(tool.lineCount).toBe(2) + }) + + test('an object `result` is unwrapped too; result_text keeps precedence when present', () => { + const store = createSessionStore() + store.apply({ type: 'message.start' }) + store.apply({ type: 'tool.start', payload: { tool_id: 'o1', name: 'terminal' } }) + store.apply({ type: 'tool.complete', payload: { tool_id: 'o1', result: { output: 'obj out', exit_code: 0 } } }) + store.apply({ type: 'tool.start', payload: { tool_id: 'o2', name: 'terminal' } }) + store.apply({ + type: 'tool.complete', + payload: { tool_id: 'o2', result: 'raw fallback', result_text: 'verbose text' } + }) + + const parts = store.state.messages.at(-1)!.parts! + const first = parts[0]! + const second = parts[1]! + if (first.type !== 'tool' || second.type !== 'tool') throw new Error('expected tool parts') + expect(first.resultText).toBe('obj out') // object envelope → its output + expect(second.resultText).toBe('verbose text') // result_text still wins when sent + }) + 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 index c106a4a7dbd..d41e6f7dc26 100644 --- a/ui-opentui/src/test/tools.test.tsx +++ b/ui-opentui/src/test/tools.test.tsx @@ -1,16 +1,20 @@ /** - * Tool renderer tests (Epic 2.2). Headless frames through the real App tree: - * the registry's default renderer turns args into LABELED FIELDS — the + * Tool renderer tests (Epics 2.2 + 2.4). 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). + * frame for tool parts, collapsed or expanded — delegate_task carries the + * Ink-parity "(/agents to monitor)" hint, and the bash renderer shows the + * command verbatim collapsed + the full (EXPANDED_MAX-capped) output expanded. + * Expansion goes through the REAL mouse path: mockMouse clicks the header row + * (found by scanning the frame). The long-output cap is asserted at the Body + * level (a tall frame would otherwise hide the trailing note). */ import { describe, expect, test } from 'vitest' -import { createSessionStore } from '../logic/store.ts' +import { createSessionStore, type ToolPartState } from '../logic/store.ts' import { App } from '../view/App.tsx' import { ThemeProvider } from '../view/theme.tsx' +import { BashToolBody } from '../view/tools/bashTool.tsx' import { renderProbe, type RenderProbe } from './lib/render.ts' type Store = ReturnType @@ -116,3 +120,117 @@ describe('tool renderer registry — labeled-args default (Epic 2.2)', () => { } }) }) + +describe('bash tool renderer — command + full output (Epic 2.4)', () => { + test('collapsed header shows the invoked command VERBATIM (args win over the gateway preview)', async () => { + const store = createSessionStore() + seedTool( + store, + // the gateway's one-line preview is truncated — args.command is the truth + { tool_id: 'b1', name: 'terminal', context: 'grep -rn needle' }, + { + tool_id: 'b1', + name: 'terminal', + args: { command: 'grep -rn needle src/ | head -5', timeout: 60 }, + duration_s: 0.2, + result_text: 'a.ts:1:needle\nb.ts:2:needle\nc.ts:3:needle' + } + ) + + const probe = await mountApp(store) + try { + const frame = await probe.waitForFrame(f => f.includes('grep -rn needle src/ | head -5')) + expect(frame).toContain('terminal') + expect(frame).toContain('grep -rn needle src/ | head -5') // verbatim, not the preview + expect(frame).toContain('(3 lines)') // output stays behind the expand affordance + expect(frame).not.toContain('a.ts:1:needle') // collapsed → no output shown + } finally { + probe.destroy() + } + }) + + test('expanded shows the $ command and the FULL (short) output', async () => { + const store = createSessionStore() + seedTool( + store, + { tool_id: 'b2', name: 'terminal' }, + { + tool_id: 'b2', + name: 'terminal', + args: { command: 'ls' }, + result_text: 'alpha.txt\nbeta.txt\ngamma.txt' + } + ) + + const probe = await mountApp(store) + try { + await clickHeader(probe, 'terminal') + const expanded = await probe.waitForFrame(f => f.includes('alpha.txt')) + expect(expanded).toContain('$ ls') // the invocation, prompt-prefixed + expect(expanded).toContain('output') // section label + expect(expanded).toContain('alpha.txt') // full output… + expect(expanded).toContain('beta.txt') + expect(expanded).toContain('gamma.txt') // …down to the last line + } finally { + probe.destroy() + } + }) + + test('long output is capped to EXPANDED_MAX with an honest "+N more lines" note', async () => { + const lines = Array.from({ length: 250 }, (_, i) => `line-${String(i + 1).padStart(3, '0')}`) + const part: ToolPartState = { + type: 'tool', + id: 'b3', + name: 'execute_code', + state: 'complete', + args: { code: 'for i in range(250): print(i)' }, + resultText: lines.join('\n') + } + // Body-level mount (tall frame so the trailing note row is on screen). + const probe = await renderProbe( + () => ( + + + + ), + { width: 80, height: 210 } + ) + try { + const frame = await probe.waitForFrame(f => f.includes('+50 more lines')) + expect(frame).toContain('$ for i in range(250): print(i)') + expect(frame).toContain('line-001') // the cap keeps the HEAD of the output + expect(frame).toContain('line-200') // …up to EXPANDED_MAX + expect(frame).not.toContain('line-201') // the rest is honestly elided + expect(frame).toContain('… +50 more lines') + } finally { + probe.destroy() + } + }) + + test('a gateway-capped result renders the tidy omitted note', async () => { + const part: ToolPartState = { + type: 'tool', + id: 'b4', + name: 'terminal', + state: 'complete', + args: { command: 'cat big.log' }, + resultText: 'tail line one\ntail line two', + omittedNote: '120 lines / 9001 chars' + } + const probe = await renderProbe( + () => ( + + + + ), + { width: 80, height: 12 } + ) + try { + const frame = await probe.waitForFrame(f => f.includes('omitted')) + expect(frame).toContain('tail line one') + expect(frame).toContain('… omitted 120 lines / 9001 chars') + } finally { + probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/view/tools/bashTool.tsx b/ui-opentui/src/view/tools/bashTool.tsx new file mode 100644 index 00000000000..48f2c61046d --- /dev/null +++ b/ui-opentui/src/view/tools/bashTool.tsx @@ -0,0 +1,74 @@ +/** + * BashTool — renderer for the shell-ish tools `terminal`, `execute_code` and + * `process` (Epic 2.4). Collapsed: the COMMAND BEING INVOKED, verbatim, on one + * line (the shell truncates to width; expanding reveals the rest). Expanded: + * the full command (`$ `-prefixed, multi-line safe) and the FULL output, kept + * to the EXPANDED_MAX cap with the honest omitted / "+N more lines" notes from + * `logic/toolOutput.ts` (via the shared ToolOutputBlock). + * + * Arg keys verified against the Python tool schemas: + * terminal → `command` (tools/terminal_tool.py TERMINAL_SCHEMA) + * execute_code → `code` (tools/code_execution_tool.py) + * process → `action` (+ `session_id`) (tools/process_registry.py PROCESS_SCHEMA) + * Falls back to the gateway's one-line argsPreview when args weren't captured. + */ +import { createMemo, For, Show } from 'solid-js' + +import type { ToolPartState } from '../../logic/store.ts' +import { truncate } from '../../logic/toolOutput.ts' +import { useTheme } from '../theme.tsx' +import { defaultSubtitle, resultLines, structuredArgs, ToolOutputBlock } from './defaultTool.tsx' +import type { ToolBodyProps, ToolRenderer } from './registry.tsx' + +/** The verbatim invocation: terminal `command` / execute_code `code` / + * process `action [session_id]`; else the gateway's argsPreview. */ +export function commandOf(part: ToolPartState): string { + const args = structuredArgs(part) + if (args) { + const cmd = args['command'] ?? args['code'] + if (typeof cmd === 'string' && cmd.trim()) return cmd + const action = args['action'] // process: the verb is the invocation + if (typeof action === 'string' && action) { + const sid = args['session_id'] + const sidText = typeof sid === 'string' || typeof sid === 'number' ? String(sid) : '' + return sidText ? `${action} ${sidText}` : action + } + } + return part.argsPreview ?? '' +} + +/** Expanded body: the full `$ command`, then the full (capped) output. */ +export function BashToolBody(props: ToolBodyProps) { + const theme = useTheme() + const command = createMemo(() => commandOf(props.part).replace(/\s+$/, '')) + return ( + + + + {(line, i) => ( + + {/* `$ ` prompt glyph (continuation lines indent under it) — chrome */} + + {i() === 0 ? '$ ' : ' '} + + {/* the command itself is copyable content */} + + {truncate(line, Math.max(1, props.width - 2))} + + + )} + + + + + ) +} + +export const bashRenderer: ToolRenderer = { + Body: BashToolBody, + // Collapsed never shows output (the header shows the command), so ANY output + // is hidden content worth expanding — as is a multi-line command. + expandable: part => resultLines(part).length > 0 || commandOf(part).includes('\n'), + // The command, verbatim, flattened to one line (the shell truncates to width). + subtitle: part => commandOf(part).replace(/\s+/g, ' ').trim() || defaultSubtitle(part) +} diff --git a/ui-opentui/src/view/tools/registry.tsx b/ui-opentui/src/view/tools/registry.tsx index 1c56df3e27c..45da7f218b5 100644 --- a/ui-opentui/src/view/tools/registry.tsx +++ b/ui-opentui/src/view/tools/registry.tsx @@ -17,6 +17,7 @@ import type { Component } from 'solid-js' import type { ToolPartState } from '../../logic/store.ts' +import { bashRenderer } from './bashTool.tsx' import { defaultRenderer } from './defaultTool.tsx' /** Props every tool Body receives: the part + usable content columns. */ @@ -40,7 +41,11 @@ export interface ToolRenderer { 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)' } + delegate_task: { ...defaultRenderer, hint: () => '(/agents to monitor)' }, + // shell-ish tools (Epic 2.4): collapsed = the command verbatim; expanded = full output. + execute_code: bashRenderer, + process: bashRenderer, + terminal: bashRenderer } /** Resolve the renderer for a tool name (default = labeled-fields fallback). */