hermes-agent/ui-opentui/src/view/tools/defaultTool.tsx

213 lines
9.4 KiB
TypeScript

/**
* 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, UNCAPPED by default (the HERMES_TUI_TOOL_OUTPUT_LINES
* env var restores a cap, e.g. `=200`) with the honest omitted / "+N more
* lines" notes when a cap applies. `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 { envOutputLines } from '../../logic/env.ts'
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 — `HERMES_TUI_TOOL_OUTPUT_LINES` (a
* TUI-only env var, not a config.yaml knob): unset/garbage/`0` → Infinity
* (UNLIMITED, the default — all tool output is viewable), positive int →
* restore that cap.
* Memory note: unlimited-by-default is safe — tool rows mount collapsed (no
* body), bodies only exist while EXPANDED, and the rolling
* HERMES_TUI_MAX_MESSAGES cap bounds the transcript's Yoga-node high-water
* mark; an expanded body's nodes free on collapse/unmount.
*/
export function expandedMaxLines(): number {
return envOutputLines(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)
}
/** Max labeled arg fields shown when expanded. */
const FIELDS_MAX = 16
/**
* The tool's structured args. SECURITY: parse `part.argsText` FIRST — when it
* came from the gateway's verbose `args_text` (tool.start) it is REDACTED
* (server.py `_tool_args_text` masks secrets), while the raw `args` dict on
* `tool.complete` is sent unredacted. The store never overwrites a tool.start
* argsText — `tool.complete` only back-fills it by stringifying raw `args`
* when absent (store.ts) — so this precedence yields redacted values whenever
* the gateway sent them, and the same raw args as before otherwise. Raw
* `part.args` is the fallback when argsText is absent or unparseable.
*/
export function structuredArgs(part: ToolPartState): Record<string, unknown> | undefined {
if (part.argsText) {
try {
const o: unknown = JSON.parse(part.argsText)
if (o && typeof o === 'object' && !Array.isArray(o)) return o as Record<string, unknown>
} catch {
/* unparseable argsText (e.g. capped mid-JSON) — fall back to raw args */
}
}
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'
}
/** 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). `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)
.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).
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 expandedMaxLines() 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(), expandedMaxLines(), 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.
* `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, props.omitFields))
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
}