opentui(v6): per-tool content fixes — clarify/skill_view/read/search/exec + tree-sitter outputs

This commit is contained in:
alt-glitch 2026-06-10 23:34:03 +05:30
parent b3efafcc73
commit 4e69fdb3be
15 changed files with 957 additions and 63 deletions

View file

@ -45,6 +45,13 @@ export interface ToolPartState {
argsText?: string
/** Structured args from `tool.complete` (always sent) — the per-tool renderers read these. */
args?: Record<string, unknown>
/** 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<string, unknown>
/** 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<string, unknown>
// 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).

View file

@ -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)

View file

@ -0,0 +1,282 @@
/**
* Per-tool content renderers (direct user feedback round, items 17):
* 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 `<code>` 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<typeof createSessionStore>
function seedTool(store: Store, start: Record<string, unknown>, complete: Record<string, unknown>) {
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<RenderProbe> {
return renderProbe(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ width, height }
)
}
async function clickHeader(probe: RenderProbe, name: string): Promise<void> {
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
})
})

View file

@ -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')

View file

@ -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>): 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 <code>
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()

View file

@ -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)

View file

@ -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 (
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Show when={command()}>
<For each={command().split('\n')}>
{(line, i) => (
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
{/* `$ ` prompt glyph (continuation lines indent under it) — chrome */}
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{i() === 0 ? '$ ' : ' '}</span>
</text>
{/* the command itself is copyable content */}
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.text }}>{truncate(line, Math.max(1, props.width - 2))}</span>
</text>
</box>
)}
</For>
<Show when={echo()}>
<Show
when={props.part.name === 'execute_code'}
fallback={
<For each={command().split('\n')}>
{(line, i) => (
<box style={{ flexDirection: 'row', flexShrink: 0 }}>
{/* `$ ` prompt glyph (continuation lines indent under it) — chrome */}
<text selectable={false}>
<span style={{ fg: theme().color.accent }}>{i() === 0 ? '$ ' : ' '}</span>
</text>
{/* the command itself is copyable content */}
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.text }}>{truncate(line, Math.max(1, props.width - 2))}</span>
</text>
</box>
)}
</For>
}
>
{/* execute_code's code argument — Tree-sitter highlighted (item 7) */}
<CodeBlock content={command()} filetype="python" />
</Show>
</Show>
<ToolOutputBlock part={props.part} width={props.width} label={Boolean(command())} />
<ToolOutputBlock part={props.part} width={props.width} label={echo()} />
</box>
)
}

View file

@ -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:
* · <question>: <answer>
* 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 (
<Show when={qa().length > 0}>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
{/* section label — chrome, not content */}
<text selectable={false}>
<span style={{ fg: theme().color.label }}>User answered:</span>
</text>
<For each={qa()}>
{({ question, answer }) => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>{'· '}</span>
<span style={{ fg: theme().color.text }}>
{truncate(`${question}: ${answer}`, Math.max(1, props.width - 2))}
</span>
</text>
)}
</For>
</box>
</Show>
)
}
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)
}
}

View file

@ -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 `<code>` renderable (CodeRenderable) with the shared
* theme-derived `syntaxStyleFor` the same style instance as markdown text and
* the file-tool `<diff>`, 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 (
<code
content={props.content}
// exactOptionalPropertyTypes: CodeOptions.filetype is `string`, so an
// unknown filetype OMITS the prop entirely (plain-text fallback).
{...(props.filetype !== undefined && { filetype: props.filetype })}
syntaxStyle={syntaxStyleFor(theme())}
conceal={false}
drawUnstyledText
wrapMode="word"
width="100%"
fg={theme().color.text}
selectable
selectionBg={theme().color.selectionBg}
/>
)
}

View file

@ -60,6 +60,28 @@ export function structuredArgs(part: ToolPartState): Record<string, unknown> | 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<string, unknown> | 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<string, unknown>
} 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 keyvalue 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 (
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<Show when={fields().length > 0}>

View file

@ -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 `<diff>` 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
* `<diff>` 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 (
<Show when={content()} fallback={<DefaultToolBody part={props.part} width={props.width} />}>
{c => (
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<CodeBlock content={c().replace(/\s+$/, '')} filetype={pathToFiletype(filePathOf(props.part))} />
<ToolOutputBlock part={props.part} width={props.width} label />
</box>
)}
</Show>
)
}
/** 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 (
<Show when={files().length > 0} fallback={<DefaultToolBody part={props.part} width={props.width} />}>
<Show when={files().length > 0} fallback={<FileFallbackBody {...props} />}>
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<For each={files()}>{file => <FileDiff file={file} label={files().length > 1} cwd={props.cwd} />}</For>
<Show when={notesOf(plan())}>{notes => <DiffNotes notes={notes()} />}</Show>

View file

@ -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 `<code>` 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 (
<Show
when={content()}
fallback={<DefaultToolBody part={props.part} width={props.width} omitFields={READ_NOISE_FIELDS} />}
>
{c => <CodeBlock content={stripLineNumbers(c())} filetype={pathToFiletype(filePathOf(props.part))} />}
</Show>
)
}
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)
}

View file

@ -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<ToolBodyProps>
}
const TOOLS: Record<string, ToolRenderer> = {
// 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
}

View file

@ -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<string, unknown>
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 (
<Show
when={lines()}
fallback={<DefaultToolBody part={props.part} width={props.width} omitFields={SEARCH_NOISE_FIELDS} />}
>
{rows => (
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<For each={rows()}>
{row => (
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>{truncate(row, Math.max(1, props.width))}</span>
</text>
)}
</For>
</box>
)}
</Show>
)
}
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)
}

View file

@ -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 <name>` (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 (
<Show when={info()}>
{i => (
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0 }}>
<text selectionBg={theme().color.selectionBg}>
{/* field label — chrome-colored, value is the content */}
<span style={{ fg: theme().color.label }}>skill</span>
<span style={{ fg: theme().color.text }}>
{` ${truncate(i().name + (i().file ? ` · ${i().file ?? ''}` : ''), Math.max(1, props.width - 7))}`}
</span>
</text>
<Show when={i().description}>
<text selectionBg={theme().color.selectionBg}>
<span style={{ fg: theme().color.muted }}>
{truncate(i().description ?? '', Math.max(1, props.width))}
</span>
</text>
</Show>
</box>
)}
</Show>
)
}
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
}
}