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