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