diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 77dcfec6202..2a496e71161 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -45,6 +45,13 @@ export interface ToolPartState { argsText?: string /** Structured args from `tool.complete` (always sent) — the per-tool renderers read these. */ args?: Record + /** Structured RESULT object from `tool.complete` (dict results only) — per-tool + * renderers extract payload fields (read_file `content`, search `matches`, + * clarify Q&A, skill_view name/description). The display string `resultText` + * can't serve this: `normalizeOutput` un-escapes literal `\n` inside JSON + * string values, so a stringified dict result no longer JSON.parses. Same + * raw-result redaction tradeoff as the unlimited-cap substitution above. */ + result?: Record /** Tool wall-clock seconds (gateway `duration_s`), shown dim in the header. */ duration?: number /** Local Date.now() stamped on `tool.start` — drives the live elapsed tick @@ -791,6 +798,11 @@ export function createSessionStore() { part.diffUnified = diffUnified part.diffStats = diffStats(diffUnified) } + // structured dict results feed the per-tool renderers (read_file + // content, search matches, clarify Q&A, skill_view description). + const resultObj = event.payload['result'] + if (resultObj && typeof resultObj === 'object' && !Array.isArray(resultObj)) + part.result = resultObj as Record // argsPreview (from tool.start `context`) is intentionally NOT overwritten. if (argsObj && typeof argsObj === 'object') { // structured args feed the per-tool renderers (labeled fields, bash command). diff --git a/ui-opentui/src/logic/toolOutput.ts b/ui-opentui/src/logic/toolOutput.ts index 94e97871e73..888f59dd326 100644 --- a/ui-opentui/src/logic/toolOutput.ts +++ b/ui-opentui/src/logic/toolOutput.ts @@ -56,15 +56,21 @@ export function normalizeOutput(text: string): string { * failed); otherwise return `raw` unchanged. (Gotcha §8 — strip the envelope.) */ /** - * When the gateway tail-caps a LARGE result it serialises the whole - * `{"output": "...", "exit_code": 0, "error": null}` envelope first, so the - * surviving tail ends mid-string with the envelope close (`…", "exit_code": 0, - * "error": null}`) — and, if the head survived, opens with `{"output": "`. The - * fragment can't be JSON.parsed, so peel those affixes off conservatively (only - * the exact gateway shape; real output won't end this way). Item 2 polish. + * When the gateway tail-caps a LARGE result it serialises the whole envelope + * first, so the surviving tail ends mid-string with the envelope close — and, + * if the head survived, opens with the envelope's prefix up to `"output": "`. + * The fragment can't be JSON.parsed, so peel those affixes off conservatively + * (only the exact gateway shapes; real output won't end this way). Two shapes + * observed live (v6fix wire capture): + * terminal/process: `{"output": "…", "exit_code": 0, "error": null}` + * execute_code: `{"status": "success", "output": "…", + * "tool_calls_made": 0, "duration_seconds": 0.21[, "error": "…"]}` + * The tail anchors on the first trailing key (`exit_code`/`tool_calls_made`) + * then allows the remaining envelope keys in any order. Items 2 + 6. */ -const ENVELOPE_HEAD = /^\s*\{\s*"output"\s*:\s*"/ -const ENVELOPE_TAIL = /"\s*,\s*"exit_code"\s*:\s*-?\d+(?:\s*,\s*"error"\s*:\s*(?:null|"(?:[^"\\]|\\.)*"))?\s*\}\s*$/ +const ENVELOPE_HEAD = /^\s*\{\s*(?:"status"\s*:\s*"[^"]*"\s*,\s*)?"output"\s*:\s*"/ +const ENVELOPE_TAIL = + /"\s*,\s*"(?:exit_code|tool_calls_made)"\s*:\s*-?\d+(?:\s*,\s*"(?:error|status|duration_seconds|exit_code|tool_calls_made)"\s*:\s*(?:null|-?\d+(?:\.\d+)?|"(?:[^"\\]|\\.)*"))*\s*\}\s*$/ function unwrapEnvelopeFragment(s: string): string { const tail = ENVELOPE_TAIL.test(s) diff --git a/ui-opentui/src/test/toolContent.test.tsx b/ui-opentui/src/test/toolContent.test.tsx new file mode 100644 index 00000000000..0c1dbee21ed --- /dev/null +++ b/ui-opentui/src/test/toolContent.test.tsx @@ -0,0 +1,282 @@ +/** + * Per-tool content renderers (direct user feedback round, items 1–7): + * clarify Q/A (never JSON), skill_view name-only, search_files pattern + + * results-only, read_file content extraction. Each `result` payload is the + * REAL wire shape captured live (v6fix tee of `python -m tui_gateway.entry` + * stdout); frame tests go through the real App tree + mouse expansion. + * The native `` visuals (Tree-sitter colors) belong to the live smoke — + * here we only assert the text content/wiring. + */ +import { describe, expect, test } from 'vitest' + +import { createSessionStore, type ToolPartState } from '../logic/store.ts' +import { App } from '../view/App.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { clarifyQA, clarifyRenderer } from '../view/tools/clarifyTool.tsx' +import { readContentOf, readRenderer, stripLineNumbers } from '../view/tools/readTool.tsx' +import { patternOf, searchRenderer, searchResultLines } from '../view/tools/searchTool.tsx' +import { skillInfoOf, skillRenderer } from '../view/tools/skillTool.tsx' +import { renderProbe, type RenderProbe } from './lib/render.ts' + +type Store = ReturnType + +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 = 100, height = 24): Promise { + return renderProbe( + () => ( + store.state.theme}> + + + ), + { width, height } + ) +} + +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) +} + +/** A settled part straight from a wire-shaped tool.complete payload. */ +function partFrom(store: Store, id: string): ToolPartState { + const last = store.state.messages[store.state.messages.length - 1] + const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === id) + expect(part).toBeDefined() + return part as ToolPartState +} + +describe('clarify renderer — Q/A, never JSON (item 4)', () => { + // REAL wire shape (v6fix live capture, tools/clarify_tool.py json.dumps). + const COMPLETE = { + args: { choices: ['red', 'green', 'blue'], question: 'Which color do you prefer?' }, + duration_s: 30.7, + name: 'clarify', + result: { + choices_offered: ['red', 'green', 'blue'], + question: 'Which color do you prefer?', + user_response: 'green' + }, + result_text: + '{"question": "Which color do you prefer?", "choices_offered": ["red", "green", "blue"], "user_response": "green"}', + tool_id: 'c1' + } + + test('clarifyQA extracts the pair from the real result shape', () => { + const store = createSessionStore() + seedTool(store, { name: 'clarify', tool_id: 'c1' }, COMPLETE) + const part = partFrom(store, 'c1') + expect(clarifyQA(part)).toEqual([{ answer: 'green', question: 'Which color do you prefer?' }]) + expect(clarifyRenderer.subtitle(part)).toBe('Which color do you prefer?: green') + expect(clarifyRenderer.expandable(part)).toBe(true) + }) + + test('frame: collapsed `q: a` subtitle; expanded `User answered:` rows; NO JSON anywhere', async () => { + const store = createSessionStore() + seedTool(store, { context: 'Which color do you prefer?', name: 'clarify', tool_id: 'c1' }, COMPLETE) + const probe = await mountApp(store) + try { + const collapsed = await probe.waitForFrame(f => f.includes('clarify')) + expect(collapsed).toContain('Which color do you prefer?: green') // compact q: a + expect(collapsed).not.toContain('{"') + + await clickHeader(probe, 'clarify') + const expanded = await probe.waitForFrame(f => f.includes('User answered:')) + expect(expanded).toContain('User answered:') + expect(expanded).toContain('· Which color do you prefer?: green') + // THE acceptance gate: the JSON result never reaches the frame + expect(expanded).not.toContain('{"') + expect(expanded).not.toContain('user_response') + expect(expanded).not.toContain('choices_offered') + } finally { + probe.destroy() + } + }) + + test('no extractable Q/A (capped/garbled result) → header only, never the raw text', () => { + const part: ToolPartState = { + id: 'c2', + name: 'clarify', + resultText: '{"question": "Which col', // gateway-capped mid-JSON + state: 'complete', + type: 'tool' + } + expect(clarifyQA(part)).toEqual([]) + expect(clarifyRenderer.expandable(part)).toBe(false) // no body → no JSON ever + }) +}) + +describe('skill_view renderer — WHICH skill, not its contents (item 5)', () => { + // REAL wire shape (v6fix live capture, trimmed to the read keys + content). + const COMPLETE = { + args: { name: 'plan' }, + duration_s: 0.02, + name: 'skill_view', + result: { + content: '---\nname: plan\n---\n# Plan mode\n\nMANY KB OF SKILL BODY…', + description: 'Plan mode: write an actionable markdown plan.', + linked_files: null, + name: 'plan', + success: true, + tags: ['planning'] + }, + tool_id: 'k1' + } + + test('skillInfoOf: name + one-line description from the real shape', () => { + const store = createSessionStore() + seedTool(store, { name: 'skill_view', tool_id: 'k1' }, COMPLETE) + const part = partFrom(store, 'k1') + expect(skillInfoOf(part)).toEqual({ + description: 'Plan mode: write an actionable markdown plan.', + name: 'plan' + }) + expect(skillRenderer.subtitle(part)).toBe('plan') + expect(skillRenderer.lines?.(part)).toHaveLength(2) + }) + + test('a linked-file view names the file it loaded', () => { + const part: ToolPartState = { + args: { file_path: 'references/api.md', name: 'opentui' }, + id: 'k2', + name: 'skill_view', + result: { content: '…file body…', file: 'references/api.md', name: 'opentui', success: true }, + state: 'complete', + type: 'tool' + } + expect(skillRenderer.subtitle(part)).toBe('opentui · references/api.md') + expect(skillRenderer.expandable(part)).toBe(false) // no description → header says it all + }) + + test('frame: collapsed shows the name; expanded shows name + description, NEVER the contents', async () => { + const store = createSessionStore() + seedTool(store, { context: 'plan', name: 'skill_view', tool_id: 'k1' }, COMPLETE) + const probe = await mountApp(store) + try { + const collapsed = await probe.waitForFrame(f => f.includes('skill_view')) + expect(collapsed).toContain('skill_view') + expect(collapsed).toContain('plan') + expect(collapsed).not.toContain('SKILL BODY') + + await clickHeader(probe, 'skill_view') + const expanded = await probe.waitForFrame(f => f.includes('Plan mode')) + expect(expanded).toContain('skill') // labeled name row + expect(expanded).toContain('Plan mode: write an actionable markdown plan.') + expect(expanded).not.toContain('SKILL BODY') // full contents suppressed + expect(expanded).not.toContain('{"') // and never JSON + expect(expanded).not.toContain('linked_files') + } finally { + probe.destroy() + } + }) +}) + +describe('search_files renderer — pattern + results only (item 2)', () => { + // REAL wire shape (v6fix live capture, SearchResult.to_dict content mode). + const COMPLETE = { + args: { path: 'ui-opentui/src', pattern: 'syntaxStyleFor' }, + duration_s: 0.1, + name: 'search_files', + result: { + matches: [ + { + content: 'export function syntaxStyleFor(theme: Theme): SyntaxStyle {', + line: 56, + path: 'src/view/markdown.tsx' + }, + { content: ' syntaxStyle={syntaxStyleFor(theme())}', line: 73, path: 'src/view/markdown.tsx' } + ], + total_count: 2 + }, + tool_id: 's1' + } + + test('searchResultLines shapes grep-style rows from the real result (and files/counts modes)', () => { + const store = createSessionStore() + seedTool(store, { name: 'search_files', tool_id: 's1' }, COMPLETE) + const part = partFrom(store, 's1') + expect(patternOf(part)).toBe('syntaxStyleFor') + expect(searchResultLines(part)).toEqual([ + 'src/view/markdown.tsx:56: export function syntaxStyleFor(theme: Theme): SyntaxStyle {', + 'src/view/markdown.tsx:73: syntaxStyle={syntaxStyleFor(theme())}' + ]) + expect(searchRenderer.subtitle(part)).toBe('syntaxStyleFor') + // files mode + expect(searchResultLines({ ...part, result: { files: ['a.py', 'b.py'], total_count: 2 } })).toEqual([ + 'a.py', + 'b.py' + ]) + // count mode + truncated note + expect(searchResultLines({ ...part, result: { counts: { 'a.py': 3 }, total_count: 3, truncated: true } })).toEqual([ + 'a.py: 3', + '… results truncated' + ]) + // zero matches is still an answer + expect(searchResultLines({ ...part, result: { total_count: 0 } })).toEqual(['no matches']) + }) + + test('frame: pattern subtitle; expanded = result rows; context/output_mode/path never shown', async () => { + const store = createSessionStore() + const complete = { + ...COMPLETE, + args: { context: 2, output_mode: 'content', path: 'ui-opentui/src', pattern: 'syntaxStyleFor', target: 'content' } + } + seedTool(store, { context: 'syntaxStyleFor', name: 'search_files', tool_id: 's1' }, complete) + const probe = await mountApp(store, 120) + try { + const collapsed = await probe.waitForFrame(f => f.includes('search_files')) + expect(collapsed).toContain('syntaxStyleFor') // the pattern IS the subtitle + + await clickHeader(probe, 'search_files') + const expanded = await probe.waitForFrame(f => f.includes('markdown.tsx:56')) + expect(expanded).toContain('src/view/markdown.tsx:56: export function syntaxStyleFor') + expect(expanded).toContain('src/view/markdown.tsx:73:') + expect(expanded).not.toContain('output_mode') // noise fields suppressed… + expect(expanded).not.toContain('context') + expect(expanded).not.toContain('target') + expect(expanded).not.toContain('total_count') // …and never the JSON envelope + expect(expanded).not.toContain('{"') + } finally { + probe.destroy() + } + }) +}) + +describe('read_file content extraction (items 1 + 7 logic)', () => { + test('readContentOf reads the dict result; stripLineNumbers peels uniform N| prefixes only', () => { + const part: ToolPartState = { + args: { limit: 50, offset: 1, path: '/p/x.py' }, + id: 'r1', + name: 'read_file', + result: { content: '1|import os\n2|\n3|print(os.sep)', file_size: 30, total_lines: 3 }, + state: 'complete', + type: 'tool' + } + expect(readContentOf(part)).toBe('1|import os\n2|\n3|print(os.sep)') + expect(stripLineNumbers(readContentOf(part) ?? '')).toBe('import os\n\nprint(os.sep)') + expect(readRenderer.expandable(part)).toBe(true) + expect(readRenderer.lines?.(part)).toEqual(['import os', '', 'print(os.sep)']) + // mixed content (not every line prefixed) stays verbatim — no false stripping + expect(stripLineNumbers('1|a\nplain line')).toBe('1|a\nplain line') + // a resumed part with only (mangled) result_text falls back gracefully + const resumed: ToolPartState = { + id: 'r2', + name: 'read_file', + resultText: '…tail…', + state: 'complete', + type: 'tool' + } + expect(readContentOf(resumed)).toBeUndefined() + expect(readRenderer.expandable(resumed)).toBe(true) // output fallback still expandable + }) +}) diff --git a/ui-opentui/src/test/toolOutput.test.ts b/ui-opentui/src/test/toolOutput.test.ts index de05841fc3f..b300190ef67 100644 --- a/ui-opentui/src/test/toolOutput.test.ts +++ b/ui-opentui/src/test/toolOutput.test.ts @@ -65,6 +65,40 @@ describe('stripToolEnvelope', () => { // real output that merely mentions exit_code is NOT mangled expect(stripToolEnvelope('the exit_code was 0 here')).toBe('the exit_code was 0 here') }) + test('unwraps the execute_code envelope — REAL wire shape from the v6fix live capture (item 6)', () => { + // {"status", "output", "tool_calls_made", "duration_seconds"} — the stdout + // lives under `output`, with leading keys before it (unlike terminal's). + expect( + stripToolEnvelope( + '{"status": "success", "output": "1\\n4\\n9\\n16\\n25\\n", "tool_calls_made": 0, "duration_seconds": 0.23}' + ) + ).toBe('1\n4\n9\n16\n25\n') + // error runs append the honest [error] suffix + expect( + stripToolEnvelope( + '{"status": "error", "output": "boom", "tool_calls_made": 0, "duration_seconds": 0.5, "error": "Script exited with code 1"}' + ) + ).toBe('boom\n[error] Script exited with code 1') + }) + test('peels a TAIL-capped execute_code envelope fragment (item 6 — resume/finite-cap path)', () => { + // gateway tail-cap cut the head: the surviving fragment ends with the + // execute_code envelope close (real shape: tool_calls_made + duration_seconds) + expect( + stripToolEnvelope( + 'metric_row_119 = 1547\\nmetric_row_120 = 1560\\n", "tool_calls_made": 0, "duration_seconds": 0.21}' + ) + ).toBe('metric_row_119 = 1547\nmetric_row_120 = 1560\n') + // …with a trailing error string too + expect( + stripToolEnvelope( + 'tail line\\n", "tool_calls_made": 2, "duration_seconds": 1.5, "error": "Script exited with code 1"}' + ) + ).toBe('tail line\n') + // head survived, tail cut → strip the execute_code prefix up to the output + expect(stripToolEnvelope('{"status": "success", "output": "line1\nline2')).toBe('line1\nline2') + // real output that merely mentions tool_calls_made is NOT mangled + expect(stripToolEnvelope('made 3 tool_calls_made: 0 mentions here')).toBe('made 3 tool_calls_made: 0 mentions here') + }) test('un-double-escapes literal \\n when they dominate (item 7 verbose tail)', () => { // double-escaped output (literal backslash-n) → real newlines expect(stripToolEnvelope('a\\nb\\nc')).toBe('a\nb\nc') diff --git a/ui-opentui/src/test/tools.test.tsx b/ui-opentui/src/test/tools.test.tsx index 6ea7a9a8260..330b81e4d34 100644 --- a/ui-opentui/src/test/tools.test.tsx +++ b/ui-opentui/src/test/tools.test.tsx @@ -18,7 +18,7 @@ import { App } from '../view/App.tsx' import { reasoningLabelStyle } from '../view/reasoningPart.tsx' import { ThemeProvider } from '../view/theme.tsx' import { toolNameStyle } from '../view/toolPart.tsx' -import { BashToolBody, commandOf } from '../view/tools/bashTool.tsx' +import { BashToolBody, commandFitsHeader, commandOf } from '../view/tools/bashTool.tsx' import { diffOutputPlan, FileToolBody } from '../view/tools/fileTool.tsx' import { renderProbe, type RenderProbe } from './lib/render.ts' @@ -154,7 +154,7 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => { } }) - test('expanded shows the $ command and the FULL (short) output', async () => { + test('one-liner that fits the header: expanded body SKIPS the $ echo — just the output (item 3)', async () => { const store = createSessionStore() seedTool( store, @@ -171,8 +171,9 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => { 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('ls') // the command stays visible — in the HEADER + expect(expanded).not.toContain('$ ls') // …so the body does NOT echo it (item 3) + expect(expanded).not.toContain('output') // no section label without an echo above it expect(expanded).toContain('alpha.txt') // full output… expect(expanded).toContain('beta.txt') expect(expanded).toContain('gamma.txt') // …down to the last line @@ -181,6 +182,48 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => { } }) + test('multi-line command: expanded body KEEPS the $ echo (the header could not show it)', async () => { + const store = createSessionStore() + seedTool( + store, + { tool_id: 'b2m', name: 'terminal' }, + { + tool_id: 'b2m', + name: 'terminal', + args: { command: 'for f in *.txt; do\n wc -l "$f"\ndone' }, + result_text: '3 alpha.txt' + } + ) + + const probe = await mountApp(store) + try { + await clickHeader(probe, 'terminal') + const expanded = await probe.waitForFrame(f => f.includes('3 alpha.txt')) + expect(expanded).toContain('$ for f in *.txt; do') // first command line, prompt-prefixed + expect(expanded).toContain('wc -l "$f"') // continuation line + expect(expanded).toContain('output') // section label separates echo from output + expect(expanded).toContain('3 alpha.txt') + } finally { + probe.destroy() + } + }) + + test('commandFitsHeader: single-line within width fits; truncated / multi-line / failed do not', () => { + const part = (over: Partial): ToolPartState => ({ + type: 'tool', + id: 'cf', + name: 'terminal', + state: 'complete', + ...over + }) + // header columns = width - name.length (see view/toolPart.tsx subWidth math) + expect(commandFitsHeader(part({ args: { command: 'ls -la' } }), 40)).toBe(true) + expect(commandFitsHeader(part({ args: { command: 'x'.repeat(40) } }), 40)).toBe(false) // truncated + expect(commandFitsHeader(part({ args: { command: 'a\nb' } }), 40)).toBe(false) // multi-line + // a failed header shows the ERROR, not the command → body must echo it + expect(commandFitsHeader(part({ args: { command: 'false' }, error: 'exit 1' }), 40)).toBe(false) + }) + test('long output with an explicit =200 cap restored gets the honest "+N more lines" note', async () => { // Output is UNCAPPED by default now — restore the old 200-line cap explicitly. const prev = process.env.HERMES_TUI_TOOL_OUTPUT_LINES @@ -205,7 +248,6 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => { ) 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 the restored cap expect(frame).not.toContain('line-201') // the rest is honestly elided @@ -280,7 +322,7 @@ describe('file tool renderer — relative path + diff stats (Epic 2.3)', () => { } }) - test('read_file gets NO diff body — expanded falls back to labeled fields + output', async () => { + test('read_file: relpath subtitle; expanded = CONTENT only — limit/offset suppressed (items 1+7)', async () => { const store = createSessionStore() store.apply({ type: 'session.info', payload: { cwd: '/home/u/proj' } }) seedTool( @@ -289,22 +331,31 @@ describe('file tool renderer — relative path + diff stats (Epic 2.3)', () => { { tool_id: 'f2', name: 'read_file', - args: { path: '/home/u/proj/notes.md', limit: 50 }, - result_text: '1|# Notes\n2|hello' + args: { limit: 50, offset: 1, path: '/home/u/proj/notes.py' }, + // REAL wire shape (v6fix capture): a dict result whose `content` + // carries the `N|`-prefixed lines; result_text mirrors it as JSON. + result: { content: '1|# Notes\n2|hello = 1', file_size: 18, total_lines: 2, truncated: false }, + result_text: '{"content": "1|# Notes\\n2|hello = 1", "total_lines": 2, "file_size": 18, "truncated": false}' } ) const probe = await mountApp(store) try { const collapsed = await probe.waitForFrame(f => f.includes('read_file')) - expect(collapsed).toContain('notes.md') // relpath subtitle + expect(collapsed).toContain('notes.py') // relpath subtitle expect(collapsed).not.toContain('+0') // no diff → no stats summary + expect(collapsed).toContain('(2 lines)') // honest count: the CONTENT lines await clickHeader(probe, 'read_file') - const expanded = await probe.waitForFrame(f => f.includes('limit')) - expect(expanded).toContain('path') // default labeled fields… - expect(expanded).toContain('50') - expect(expanded).toContain('# Notes') // …and the output body + const expanded = await probe.waitForFrame(f => f.includes('# Notes')) + expect(expanded).toContain('# Notes') // the content, through the native + expect(expanded).toContain('hello = 1') + expect(expanded).not.toContain('1|') // line-number prefixes stripped + expect(expanded).not.toContain('limit') // noise arg-fields suppressed… + expect(expanded).not.toContain('offset') + expect(expanded).not.toContain('50') + expect(expanded).not.toContain('total_lines') // …and never the JSON envelope + expect(expanded).not.toContain('{"') expect(expanded).not.toContain('@@') // never a diff } finally { probe.destroy() diff --git a/ui-opentui/src/view/toolPart.tsx b/ui-opentui/src/view/toolPart.tsx index 2e088c996cd..94b0711d492 100644 --- a/ui-opentui/src/view/toolPart.tsx +++ b/ui-opentui/src/view/toolPart.tsx @@ -101,7 +101,9 @@ export function ToolPart(props: { part: ToolPartState }) { // 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 lines = () => resultLines(props.part) + // "(N lines)" counts what the renderer's body will actually show (per-tool + // `lines`, e.g. read_file's content), not the raw resultText. + const lines = () => renderer().lines?.(props.part) ?? resultLines(props.part) const running = () => props.part.state === 'running' // Expandable when the renderer says there's a body to reveal beyond the header. const collapsible = () => !running() && renderer().expandable(props.part) diff --git a/ui-opentui/src/view/tools/bashTool.tsx b/ui-opentui/src/view/tools/bashTool.tsx index 33733c850fb..364388d816e 100644 --- a/ui-opentui/src/view/tools/bashTool.tsx +++ b/ui-opentui/src/view/tools/bashTool.tsx @@ -2,11 +2,18 @@ * 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 — - * uncapped by default (HERMES_TUI_TOOL_OUTPUT_LINES restores a cap, e.g. - * `=200`) with the honest omitted / "+N more lines" notes from + * the full output — uncapped by default (HERMES_TUI_TOOL_OUTPUT_LINES restores + * a cap, e.g. `=200`) with the honest omitted / "+N more lines" notes from * `logic/toolOutput.ts` (via the shared ToolOutputBlock). * + * The command echo is shown only when the header could NOT (feedback item 3: + * "the dropdown header and the info below it is mostly the same for one-liner + * commands") — i.e. multi-line or header-truncated commands. When echoed, + * terminal/process keep the `$ `-prefixed plain lines (bash output stays + * plain), while execute_code's `code` argument goes through the shared + * Tree-sitter CodeBlock as Python (item 7 — the tool's schema has a single + * `code` arg and is always Python). + * * Arg keys verified against the Python tool schemas: * terminal → `command` (tools/terminal_tool.py TERMINAL_SCHEMA) * execute_code → `code` (tools/code_execution_tool.py) @@ -18,6 +25,7 @@ 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 { CodeBlock } from './codeBlock.tsx' import { defaultSubtitle, resultLines, structuredArgs, ToolOutputBlock } from './defaultTool.tsx' import type { ToolBodyProps, ToolRenderer } from './registry.tsx' @@ -40,29 +48,55 @@ export function commandOf(part: ToolPartState): string { return part.argsPreview ?? '' } -/** Expanded body: the full `$ command`, then the full (capped) output. */ +/** + * True when the collapsed header already shows the WHOLE command (item 3) — + * single line AND untruncated. Mirrors the header math in `view/toolPart.tsx`: + * the subtitle gets `bodyWidth - name - 2` columns while the Body gets + * `bodyWidth - 2`, so the header's subtitle width is `width - name.length`. + * A failed part's header shows the ERROR instead of the command, so the body + * must echo it again. + */ +export function commandFitsHeader(part: ToolPartState, width: number): boolean { + if (part.error) return false + const cmd = commandOf(part) + if (cmd.includes('\n')) return false + const flat = cmd.replace(/\s+/g, ' ').trim() + return flat.length <= Math.max(1, width - part.name.length) +} + +/** Expanded body: the command echo (only when the header truncated it), then + * the full (capped) output. */ export function BashToolBody(props: ToolBodyProps) { const theme = useTheme() const command = createMemo(() => commandOf(props.part).replace(/\s+$/, '')) + const echo = createMemo(() => Boolean(command()) && !commandFitsHeader(props.part, props.width)) 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))} - - - )} - + + + {(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))} + + + )} + + } + > + {/* execute_code's code argument — Tree-sitter highlighted (item 7) */} + + - + ) } diff --git a/ui-opentui/src/view/tools/clarifyTool.tsx b/ui-opentui/src/view/tools/clarifyTool.tsx new file mode 100644 index 00000000000..4522a686091 --- /dev/null +++ b/ui-opentui/src/view/tools/clarifyTool.tsx @@ -0,0 +1,82 @@ +/** + * ClarifyTool — renderer for `clarify` (feedback item 4: "c'mon lol i see + * output in json xD" — the settled clarify part rendered its raw JSON result, + * a north-star violation). + * + * Wire shape (verified live, v6fix capture + tools/clarify_tool.py): + * args → {"question": "…", "choices": ["…"]?} + * result → {"question": "…", "choices_offered": ["…"]|null, "user_response": "…"} + * + * Collapsed: compact `question: answer`. Expanded (the user's sketch): + * User answered: + * · : + * One `·` line per Q/A — a clarify result carries one pair today; the renderer + * maps whatever pairs it finds so a future multi-question result stays right. + * NEVER JSON: when no Q/A can be extracted there is no body (header only). + */ +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, structuredResult } from './defaultTool.tsx' +import type { ToolBodyProps, ToolRenderer } from './registry.tsx' + +export interface ClarifyQA { + question: string + answer: string +} + +/** The Q/A pairs from the settled result (today: exactly one), else []. */ +export function clarifyQA(part: ToolPartState): ClarifyQA[] { + const r = structuredResult(part) + if (!r) return [] + const question = typeof r['question'] === 'string' ? r['question'].trim() : '' + const answer = typeof r['user_response'] === 'string' ? r['user_response'].trim() : '' + if (!question || !answer) return [] + return [{ answer, question }] +} + +/** Expanded body: `User answered:` + one `· question: answer` row per pair. */ +export function ClarifyToolBody(props: ToolBodyProps) { + const theme = useTheme() + const qa = createMemo(() => clarifyQA(props.part)) + return ( + 0}> + + {/* section label — chrome, not content */} + + User answered: + + + {({ question, answer }) => ( + + {'· '} + + {truncate(`${question}: ${answer}`, Math.max(1, props.width - 2))} + + + )} + + + + ) +} + +export const clarifyRenderer: ToolRenderer = { + Body: ClarifyToolBody, + // Only the extracted Q/A is worth expanding — never the JSON result. + expandable: part => clarifyQA(part).length > 0, + // Honest "(N lines)": label + one row per pair. + lines: part => { + const qa = clarifyQA(part) + return qa.length > 0 ? ['User answered:', ...qa.map(p => `· ${p.question}: ${p.answer}`)] : [] + }, + // Collapsed: compact `question: answer` once settled; the question while running. + subtitle: part => { + const qa = clarifyQA(part) + const first = qa[0] + if (first) return `${first.question}: ${first.answer}`.replace(/\s+/g, ' ').trim() + return defaultSubtitle(part) + } +} diff --git a/ui-opentui/src/view/tools/codeBlock.tsx b/ui-opentui/src/view/tools/codeBlock.tsx new file mode 100644 index 00000000000..8c12c5127e5 --- /dev/null +++ b/ui-opentui/src/view/tools/codeBlock.tsx @@ -0,0 +1,38 @@ +/** + * CodeBlock — shared Tree-sitter-highlighted source block for tool bodies + * (item 7): read_file output (filetype from the path extension), execute_code's + * `code` argument (always Python), and write_file content when shown without a + * diff. Uses the NATIVE `` renderable (CodeRenderable) with the shared + * theme-derived `syntaxStyleFor` — the same style instance as markdown text and + * the file-tool ``, so highlighting is consistent across the transcript. + * + * Unknown filetype → `filetype` stays undefined and the renderable draws plain + * text (`drawUnstyledText` keeps content visible even before/without a + * grammar). `conceal` is OFF: tool bodies show SOURCE verbatim (concealment is + * for prose markdown, not for a file the user asked to see). No height — like + * the file-tool diff it sizes to content so it never scrolls internally + * against the transcript's outer scrollbox. Headless caveat: highlighting + * settles async; tests pin wiring/logic, visuals belong to the live smoke. + */ +import { useTheme } from '../theme.tsx' +import { syntaxStyleFor } from '../markdown.tsx' + +export function CodeBlock(props: { content: string; filetype?: string | undefined }) { + const theme = useTheme() + return ( + + ) +} diff --git a/ui-opentui/src/view/tools/defaultTool.tsx b/ui-opentui/src/view/tools/defaultTool.tsx index 7ef655e89c4..51488fccbd9 100644 --- a/ui-opentui/src/view/tools/defaultTool.tsx +++ b/ui-opentui/src/view/tools/defaultTool.tsx @@ -60,6 +60,28 @@ export function structuredArgs(part: ToolPartState): Record | u return part.args } +/** + * The tool's structured RESULT object — `part.result` (the raw dict shipped on + * tool.complete, captured by the store) first; falls back to parsing + * `part.resultText` when it still looks like intact JSON (covers resumed + * sessions, which hydrate result_text only). NOTE the fallback is best-effort: + * the store's normalizeOutput un-escapes literal `\n` inside JSON string + * values, so multi-line payloads (read_file content, skill_view content) only + * survive via `part.result`. + */ +export function structuredResult(part: ToolPartState): Record | undefined { + if (part.result) return part.result + const s = (part.resultText ?? '').trim() + if (!s.startsWith('{')) return undefined + try { + const o: unknown = JSON.parse(s) + if (o && typeof o === 'object' && !Array.isArray(o)) return o as Record + } catch { + /* capped/mangled JSON — no structured result */ + } + return undefined +} + function isPrimitive(v: unknown): v is string | number | boolean { return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' } @@ -77,11 +99,18 @@ function fieldValue(v: unknown): string { 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]> { +/** + * Labeled key→value rows for the expanded args (NEVER raw JSON). `omit` is the + * per-tool NOISE-FIELD suppression hook (items 1 + 2): a renderer passes the + * arg keys its expanded view shouldn't repeat (read_file's limit/offset, + * search_files' context/output_mode, keys already shown as the subtitle). + */ +export function argFields(part: ToolPartState, omit?: readonly string[]): Array<[string, string]> { const obj = structuredArgs(part) if (!obj) return [] - const entries = Object.entries(obj).map(([k, v]): [string, string] => [k, fieldValue(v)]) + const entries = Object.entries(obj) + .filter(([k]) => !omit?.includes(k)) + .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). @@ -141,10 +170,11 @@ export function ToolOutputBlock(props: { part: ToolPartState; width: number; lab ) } -/** Expanded body: labeled arg fields, then the (capped) output block. */ -export function DefaultToolBody(props: ToolBodyProps) { +/** Expanded body: labeled arg fields, then the (capped) output block. + * `omitFields` = the per-tool noise-field suppression list (see argFields). */ +export function DefaultToolBody(props: ToolBodyProps & { omitFields?: readonly string[] }) { const theme = useTheme() - const fields = createMemo(() => argFields(props.part)) + const fields = createMemo(() => argFields(props.part, props.omitFields)) return ( 0}> diff --git a/ui-opentui/src/view/tools/fileTool.tsx b/ui-opentui/src/view/tools/fileTool.tsx index c51504e3ad8..5da6010cc3a 100644 --- a/ui-opentui/src/view/tools/fileTool.tsx +++ b/ui-opentui/src/view/tools/fileTool.tsx @@ -1,13 +1,15 @@ /** - * FileTool — renderer for the file tools `write_file`, `patch`, `read_file` and - * `skill_manage` (Epic 2.3). Collapsed: the file path RELATIVE to the session - * cwd, plus a themed `+N −M` change summary (rendered by the shell from - * `stats`). Expanded: the FULL unified diff (gateway `diff_unified`, 512KB- - * capped) through the NATIVE `` renderable — unified view (the transcript - * is column-constrained), word-wrapped, line-numbered, themed. Multi-file diffs - * are split per file (DiffRenderable parses only the FIRST file of a multi-file - * diff) with a path label above each section. `read_file` (or any run without a - * diff) falls back to the default labeled fields + output body. + * FileTool — renderer for the file-EDIT tools `write_file`, `patch` and + * `skill_manage` (Epic 2.3; `read_file` has its own renderer, readTool.tsx). + * Collapsed: the file path RELATIVE to the session cwd, plus a themed `+N −M` + * change summary (rendered by the shell from `stats`). Expanded: the FULL + * unified diff (gateway `diff_unified`, 512KB-capped) through the NATIVE + * `` renderable — unified view (the transcript is column-constrained), + * word-wrapped, line-numbered, themed. Multi-file diffs are split per file + * (DiffRenderable parses only the FIRST file of a multi-file diff) with a path + * label above each section. A run without a diff falls back to the default + * labeled fields + output body — except write_file, whose `content` arg shows + * as a highlighted CodeBlock (item 7). * * Arg keys verified against the Python tool schemas (tools/file_tools.py * READ_FILE_SCHEMA / WRITE_FILE_SCHEMA / PATCH_SCHEMA: `path`; @@ -25,6 +27,7 @@ import { type DiffFileSection, relativizePath, splitUnifiedDiff } from '../../lo import type { ToolPartState } from '../../logic/store.ts' import { syntaxStyleFor } from '../markdown.tsx' import { useTheme } from '../theme.tsx' +import { CodeBlock } from './codeBlock.tsx' import { DefaultToolBody, defaultRenderer, defaultSubtitle, structuredArgs, ToolOutputBlock } from './defaultTool.tsx' import type { ToolBodyProps, ToolRenderer } from './registry.tsx' @@ -169,12 +172,35 @@ function DiffNotes(props: { notes: Array<[string, string]> }) { ) } -/** Expanded body: per-file native diffs (+ non-redundant output), else default. */ +/** write_file's `content` arg, when it's a real string (structuredArgs first). */ +function writeContentOf(part: ToolPartState): string | undefined { + if (part.name !== 'write_file') return undefined + const c = structuredArgs(part)?.['content'] + return typeof c === 'string' && c.trim() ? c : undefined +} + +/** No-diff fallback body: write_file shows its CONTENT highlighted (item 7 — + * the labeled-field flattening was useless for source); others stay default. */ +function FileFallbackBody(props: ToolBodyProps) { + const content = createMemo(() => writeContentOf(props.part)) + return ( + }> + {c => ( + + + + + )} + + ) +} + +/** Expanded body: per-file native diffs (+ non-redundant output), else fallback. */ export function FileToolBody(props: ToolBodyProps) { const files = createMemo(() => (props.part.diffUnified ? splitUnifiedDiff(props.part.diffUnified) : [])) const plan = createMemo(() => diffOutputPlan(props.part)) return ( - 0} fallback={}> + 0} fallback={}> {file => 1} cwd={props.cwd} />} {notes => } diff --git a/ui-opentui/src/view/tools/readTool.tsx b/ui-opentui/src/view/tools/readTool.tsx new file mode 100644 index 00000000000..031ac424cc8 --- /dev/null +++ b/ui-opentui/src/view/tools/readTool.tsx @@ -0,0 +1,78 @@ +/** + * ReadTool — renderer for `read_file` (feedback items 1 + 7: "limit and offset + * not rlly needed as separate lines. just show path and output"). + * + * Collapsed: the cwd-relative path (subtitle, like the other file tools). + * Expanded: ONLY the file content — no labeled arg rows (the path is already + * the subtitle; limit/offset are call mechanics, not content) — rendered + * through the native `` renderable with the filetype derived from the + * path extension (Tree-sitter highlighting; unknown extension → plain text). + * + * Wire shape (verified live, v6fix capture): the read_file result is a JSON + * dict `{"content": "1|line…", "total_lines": N, "file_size": N, …}` whose + * `content` carries `N|`-prefixed lines (tools/file_operations.py + * `f"{i}|{line}"`). The prefixes are stripped before highlighting (they'd + * break the grammar parse); when a content payload isn't available (resumed + * session with only mangled result_text, error results) the body falls back to + * the default labeled-fields renderer with the noise fields suppressed. + */ +import { pathToFiletype } from '@opentui/core' +import { createMemo, Show } from 'solid-js' + +import { relativizePath } from '../../logic/diff.ts' +import type { ToolPartState } from '../../logic/store.ts' +import { CodeBlock } from './codeBlock.tsx' +import { DefaultToolBody, defaultSubtitle, resultLines, structuredResult } from './defaultTool.tsx' +import { filePathOf } from './fileTool.tsx' +import type { ToolBodyProps, ToolRenderer } from './registry.tsx' + +/** Arg keys the expanded view suppresses (item 1): the path is the collapsed + * subtitle; limit/offset are pagination mechanics, not content. */ +export const READ_NOISE_FIELDS = ['path', 'offset', 'limit'] as const + +/** The file content from the structured result (string, non-empty), else undefined. */ +export function readContentOf(part: ToolPartState): string | undefined { + const c = structuredResult(part)?.['content'] + return typeof c === 'string' && c.length > 0 ? c : undefined +} + +/** + * Strip the read tool's `N|` line-number prefixes (only when EVERY non-empty + * line carries one — genuine file content that happens to contain `12|x` lines + * mixed with unprefixed ones is left verbatim). Highlighting needs the bare + * source; the prefixes also make mouse-copy of the body paste-able. + */ +export function stripLineNumbers(content: string): string { + const lines = content.split('\n') + const prefixed = /^\d+\|/ + if (!lines.some(l => l.length > 0)) return content + if (!lines.every(l => l.length === 0 || prefixed.test(l))) return content + return lines.map(l => l.replace(prefixed, '')).join('\n') +} + +/** Expanded body: the highlighted file content only (fallback: default fields + * minus the noise keys + output). */ +export function ReadToolBody(props: ToolBodyProps) { + const content = createMemo(() => readContentOf(props.part)) + return ( + } + > + {c => } + + ) +} + +export const readRenderer: ToolRenderer = { + Body: ReadToolBody, + // Content (or any output) is hidden behind the header → worth expanding. + expandable: part => Boolean(readContentOf(part)) || resultLines(part).length > 0, + // Honest "(N lines)": count the content the body actually shows. + lines: part => { + const c = readContentOf(part) + return c ? stripLineNumbers(c).replace(/\s+$/, '').split('\n') : resultLines(part) + }, + // The target path, relative to the session cwd (file-tool house rule). + subtitle: (part, cwd) => relativizePath(filePathOf(part), cwd) || defaultSubtitle(part) +} diff --git a/ui-opentui/src/view/tools/registry.tsx b/ui-opentui/src/view/tools/registry.tsx index 60a8bafc721..3ce74836c2b 100644 --- a/ui-opentui/src/view/tools/registry.tsx +++ b/ui-opentui/src/view/tools/registry.tsx @@ -19,8 +19,12 @@ import type { Component } from 'solid-js' import type { DiffStats } from '../../logic/diff.ts' import type { ToolPartState } from '../../logic/store.ts' import { bashRenderer } from './bashTool.tsx' +import { clarifyRenderer } from './clarifyTool.tsx' import { defaultRenderer } from './defaultTool.tsx' import { fileRenderer } from './fileTool.tsx' +import { readRenderer } from './readTool.tsx' +import { searchRenderer } from './searchTool.tsx' +import { skillRenderer } from './skillTool.tsx' /** Props every tool Body receives: the part + usable content columns. */ export interface ToolBodyProps { @@ -40,23 +44,39 @@ export interface ToolRenderer { stats?: (part: ToolPartState) => DiffStats | undefined /** Whether the part has expandable content beyond the header (when settled). */ expandable: (part: ToolPartState) => boolean + /** The lines the expanded body will actually show — drives the honest + * "(N lines)" header count. Defaults to the raw resultText lines. */ + lines?: (part: ToolPartState) => string[] /** The expanded body, rendered inside the shared left-bordered frame. */ Body: Component } const TOOLS: Record = { + // clarify (item 4): collapsed = `question: answer`; expanded = `User + // answered:` + `· q: a` rows — NEVER the raw JSON result. + clarify: clarifyRenderer, // 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)' }, - // shell-ish tools (Epic 2.4): collapsed = the command verbatim; expanded = full output. + // shell-ish tools (Epic 2.4): collapsed = the command verbatim; expanded = + // full output (+ the command echo only when the header truncated it, item 3; + // execute_code's code arg is Tree-sitter highlighted, item 7). execute_code: bashRenderer, process: bashRenderer, terminal: bashRenderer, - // file tools (Epic 2.3): collapsed = cwd-relative path + `+N −M`; expanded = - // the FULL native diff (write_file/patch/skill_manage) or labeled fields (read_file). + // file-edit tools (Epic 2.3): collapsed = cwd-relative path + `+N −M`; + // expanded = the FULL native diff. patch: fileRenderer, - read_file: fileRenderer, + // read_file (items 1 + 7): collapsed = relpath; expanded = highlighted + // content only (limit/offset suppressed). + read_file: readRenderer, + // search_files (item 2): collapsed = the pattern; expanded = grep-style + // result lines only (context/output_mode/… suppressed). + search_files: searchRenderer, skill_manage: fileRenderer, + // skill_view (item 5): WHICH skill was loaded (+ one-line description) — + // never the full skill contents. + skill_view: skillRenderer, write_file: fileRenderer } diff --git a/ui-opentui/src/view/tools/searchTool.tsx b/ui-opentui/src/view/tools/searchTool.tsx new file mode 100644 index 00000000000..401fcc8c298 --- /dev/null +++ b/ui-opentui/src/view/tools/searchTool.tsx @@ -0,0 +1,106 @@ +/** + * SearchTool — renderer for `search_files` (feedback item 2: "context, + * output_mode kinda not needed — just pattern and output"). + * + * Collapsed: the PATTERN (arg key verified against tools/file_tools.py + * SEARCH_FILES_SCHEMA — required `pattern`). Expanded: ONLY the results, shaped + * as grep-style lines — no labeled arg rows (context/output_mode/limit/offset/ + * path/target/file_glob are call mechanics the user didn't ask to re-read). + * + * Wire shape (verified live + tools/file_operations.py SearchResult.to_dict): + * content mode → {"total_count": N, "matches": [{path, line, content}…]} + * files mode → {"total_count": N, "files": ["…"]} + * count mode → {"total_count": N, "counts": {"path": N}} + * any mode → optional "truncated": true + * Fallback (no structured result): default labeled fields minus the noise keys. + */ +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 { DefaultToolBody, defaultSubtitle, resultLines, structuredArgs, structuredResult } from './defaultTool.tsx' +import type { ToolBodyProps, ToolRenderer } from './registry.tsx' + +/** Arg keys the expanded view suppresses (item 2): pattern is the subtitle; + * the rest are search mechanics, not results. */ +export const SEARCH_NOISE_FIELDS = [ + 'pattern', + 'context', + 'output_mode', + 'target', + 'path', + 'file_glob', + 'limit', + 'offset' +] as const + +/** The search pattern, via structuredArgs (redacted args_text precedence). */ +export function patternOf(part: ToolPartState): string { + const p = structuredArgs(part)?.['pattern'] + return typeof p === 'string' ? p.trim() : '' +} + +/** Grep-style result lines from the structured result, else undefined. */ +export function searchResultLines(part: ToolPartState): string[] | undefined { + const r = structuredResult(part) + if (!r) return undefined + const out: string[] = [] + const matches = r['matches'] + if (Array.isArray(matches)) { + for (const m of matches) { + if (!m || typeof m !== 'object') continue + const o = m as Record + const path = typeof o['path'] === 'string' ? o['path'] : '' + const line = typeof o['line'] === 'number' ? o['line'] : undefined + const content = typeof o['content'] === 'string' ? o['content'].replace(/\s+$/, '') : '' + out.push(`${path}${line === undefined ? '' : `:${line}`}: ${content}`) + } + } + const files = r['files'] + if (Array.isArray(files)) for (const f of files) if (typeof f === 'string') out.push(f) + const counts = r['counts'] + if (counts && typeof counts === 'object' && !Array.isArray(counts)) + for (const [path, n] of Object.entries(counts)) out.push(`${path}: ${String(n)}`) + if (out.length === 0) { + // a structured result with no result rows IS the answer: nothing matched + if (typeof r['total_count'] === 'number') return ['no matches'] + return undefined + } + if (r['truncated'] === true) out.push('… results truncated') + return out +} + +/** Expanded body: the result lines only (fallback: default fields minus noise). */ +export function SearchToolBody(props: ToolBodyProps) { + const theme = useTheme() + const lines = createMemo(() => searchResultLines(props.part)) + return ( + } + > + {rows => ( + + + {row => ( + + {truncate(row, Math.max(1, props.width))} + + )} + + + )} + + ) +} + +export const searchRenderer: ToolRenderer = { + Body: SearchToolBody, + // Any result rows (or raw output) are hidden behind the header. + expandable: part => (searchResultLines(part)?.length ?? 0) > 0 || resultLines(part).length > 0, + // Honest "(N lines)": the rows the body actually shows. + lines: part => searchResultLines(part) ?? resultLines(part), + // The pattern, verbatim (it already IS the gateway preview for search_files). + subtitle: part => patternOf(part) || defaultSubtitle(part) +} diff --git a/ui-opentui/src/view/tools/skillTool.tsx b/ui-opentui/src/view/tools/skillTool.tsx new file mode 100644 index 00000000000..d9a4163daec --- /dev/null +++ b/ui-opentui/src/view/tools/skillTool.tsx @@ -0,0 +1,93 @@ +/** + * SkillTool — renderer for `skill_view` (feedback item 5: "don't show the + * skill output — just show WHICH skill was loaded"). The result is a JSON dict + * whose `content` is the ENTIRE skill body (often many KB) — pure noise in the + * transcript (the agent consumed it, the user only needs the fact of the load). + * + * Collapsed: `skill_view ` (plus the linked file path when the call + * loaded one). Expanded: the name + its one-line description (cheap: the + * result already carries `description`); the full contents stay suppressed. + * + * Wire shape (verified live, v6fix capture + tools/skills_tool.py): + * args → {"name": "…", "file_path"?: "references/…"} + * result → {"success": true, "name": "…", "description": "…", "content": …} + * (file view → {"success": true, "name": "…", "file": "…", "content": …}) + */ +import { createMemo, Show } from 'solid-js' + +import type { ToolPartState } from '../../logic/store.ts' +import { truncate } from '../../logic/toolOutput.ts' +import { useTheme } from '../theme.tsx' +import { defaultSubtitle, structuredArgs, structuredResult } from './defaultTool.tsx' +import type { ToolBodyProps, ToolRenderer } from './registry.tsx' + +export interface SkillInfo { + name: string + /** Linked file loaded by this call (args `file_path` / result `file`), if any. */ + file?: string + /** One-line description from the result (main SKILL.md views only). */ + description?: string +} + +/** Which skill (and optional linked file) this call loaded. */ +export function skillInfoOf(part: ToolPartState): SkillInfo | undefined { + const args = structuredArgs(part) + const result = structuredResult(part) + const name = + (typeof result?.['name'] === 'string' && result['name'].trim()) || + (typeof args?.['name'] === 'string' && args['name'].trim()) || + '' + if (!name) return undefined + const info: SkillInfo = { name } + const file = args?.['file_path'] ?? result?.['file'] + if (typeof file === 'string' && file.trim()) info.file = file.trim() + const description = result?.['description'] + if (typeof description === 'string' && description.trim()) info.description = description.replace(/\s+/g, ' ').trim() + return info +} + +/** Expanded body: name (+ linked file) and the one-line description — never the contents. */ +export function SkillToolBody(props: ToolBodyProps) { + const theme = useTheme() + const info = createMemo(() => skillInfoOf(props.part)) + return ( + + {i => ( + + + {/* field label — chrome-colored, value is the content */} + skill + + {` ${truncate(i().name + (i().file ? ` · ${i().file ?? ''}` : ''), Math.max(1, props.width - 7))}`} + + + + + + {truncate(i().description ?? '', Math.max(1, props.width))} + + + + + )} + + ) +} + +export const skillRenderer: ToolRenderer = { + Body: SkillToolBody, + // Only the name/description summary is worth expanding — never the contents. + expandable: part => Boolean(skillInfoOf(part)?.description), + // Honest "(N lines)": what the body actually shows (suppresses the JSON count). + lines: part => { + const i = skillInfoOf(part) + if (!i) return [] + return i.description ? [i.name, i.description] : [i.name] + }, + // Collapsed: WHICH skill was loaded (+ the linked file when one was). + subtitle: part => { + const i = skillInfoOf(part) + if (!i) return defaultSubtitle(part) + return i.file ? `${i.name} · ${i.file}` : i.name + } +}