mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v5/item2): surface tool-call args + de-pad output
The gateway already ships per-tool arg metadata the client was discarding:
`context` (build_tool_preview's primary-arg line, always sent), `args` (full
dict on complete), `args_text` (redacted JSON, verbose), `duration_s`. Capture
them on the tool part and render free-code style:
- collapsed header: `▶ name <arg-preview> · <duration> (N lines)` — args are
finally visible without expanding (the core item-2 complaint).
- expanded: a single left-bordered (`│`) column with a key:value args block
(suppressed when the lone arg is already the header preview — judge nit) then
the output block.
- strip the gateway's `[showing verbose tail; omitted N chars]` banner into a
tidy `… omitted N chars` note; unwrap tail-capped `{"output":…}` envelope
fragments so the last line isn't a dangling JSON tail.
Left bar is a border glyph (opencode BlockTool style), not a bg fill — cleaner
and renders faithfully. +4 unit tests, +1 render test (78 pass).
This commit is contained in:
parent
c9540570ae
commit
aec752faa3
6 changed files with 271 additions and 34 deletions
|
|
@ -14,7 +14,7 @@
|
|||
import { createStore, produce } from 'solid-js/store'
|
||||
|
||||
import type { GatewayEvent, GatewaySkinDecoded } from '../boundary/schema/GatewayEvent.ts'
|
||||
import { stripToolEnvelope } from './toolOutput.ts'
|
||||
import { stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
|
||||
|
||||
/** A tool call inside an assistant turn (matched start↔complete by `id`=tool_id). */
|
||||
|
|
@ -29,6 +29,14 @@ export interface ToolPartState {
|
|||
summary?: string
|
||||
error?: string
|
||||
lineCount?: number
|
||||
/** One-line primary-arg preview from gateway `context` (always sent; redaction-safe). */
|
||||
argsPreview?: string
|
||||
/** Full args (pretty JSON) for the expanded view — `args_text` (redacted) or stringified `args`. */
|
||||
argsText?: string
|
||||
/** 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"). */
|
||||
omittedNote?: string
|
||||
}
|
||||
|
||||
/** One ordered piece of an assistant turn (§7). */
|
||||
|
|
@ -484,10 +492,17 @@ export function createSessionStore() {
|
|||
const id = readStr(event.payload, 'tool_id')
|
||||
if (!id) break
|
||||
const name = readStr(event.payload, 'name') ?? 'tool'
|
||||
// `context` = build_tool_preview's primary-arg line (always sent); `args_text`
|
||||
// = redacted full-arg JSON (verbose mode only). Surfacing these is item 2.
|
||||
const argsPreview = readStr(event.payload, 'context')
|
||||
const argsText = readStr(event.payload, 'args_text')
|
||||
setState(
|
||||
produce(draft => {
|
||||
const live = ensureAssistant(draft)
|
||||
;(live.parts ??= []).push({ type: 'tool', id, name, state: 'running' })
|
||||
const part: ToolPartState = { type: 'tool', id, name, state: 'running' }
|
||||
if (argsPreview) part.argsPreview = argsPreview
|
||||
if (argsText) part.argsText = argsText
|
||||
;(live.parts ??= []).push(part)
|
||||
})
|
||||
)
|
||||
break
|
||||
|
|
@ -498,8 +513,15 @@ export function createSessionStore() {
|
|||
const name = readStr(event.payload, 'name')
|
||||
const error = readStr(event.payload, 'error')
|
||||
const summary = readStr(event.payload, 'summary')
|
||||
const resultText = stripToolEnvelope(readStr(event.payload, 'result_text') ?? summary ?? '')
|
||||
// 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 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
|
||||
// when verbose `args_text` wasn't captured on start. `duration_s` → header.
|
||||
const argsObj = event.payload['args']
|
||||
const duration = readOptNum(event.payload, 'duration_s')
|
||||
setState(
|
||||
produce(draft => {
|
||||
let part = findToolPart(draft, id)
|
||||
|
|
@ -514,6 +536,16 @@ export function createSessionStore() {
|
|||
if (resultText) part.resultText = resultText
|
||||
if (summary) part.summary = summary
|
||||
if (error) part.error = error
|
||||
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 */
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
break
|
||||
|
|
|
|||
|
|
@ -40,9 +40,27 @@ export function normalizeOutput(text: string): string {
|
|||
* object, return its `output` (plus a compact error/exit suffix when the command
|
||||
* 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.
|
||||
*/
|
||||
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*$/
|
||||
|
||||
function unwrapEnvelopeFragment(s: string): string {
|
||||
const tail = ENVELOPE_TAIL.test(s)
|
||||
const head = ENVELOPE_HEAD.test(s)
|
||||
if (!tail && !head) return s
|
||||
return s.replace(ENVELOPE_HEAD, '').replace(ENVELOPE_TAIL, '')
|
||||
}
|
||||
|
||||
export function stripToolEnvelope(raw: string): string {
|
||||
const s = (raw ?? '').trim()
|
||||
if (!s.startsWith('{')) return normalizeOutput(raw ?? '')
|
||||
if (!s.startsWith('{')) return normalizeOutput(unwrapEnvelopeFragment(raw ?? ''))
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(s)
|
||||
|
|
@ -56,9 +74,24 @@ export function stripToolEnvelope(raw: string): string {
|
|||
return normalizeOutput(out)
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through and return raw
|
||||
// not parseable as a whole — maybe a tail-capped envelope fragment
|
||||
}
|
||||
return normalizeOutput(raw ?? '')
|
||||
return normalizeOutput(unwrapEnvelopeFragment(raw ?? ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway caps verbose tool output to a tail and PREFIXES a literal label
|
||||
* (`tui_gateway/server.py:_cap_tui_verbose_text`):
|
||||
* `[showing verbose tail; omitted 5 lines / 234 chars]\n<tail>`
|
||||
* `[showing verbose tail; omitted 512 chars]\n<tail>`
|
||||
* The raw label is neither useful nor pretty (item 2). Strip it off and hand the
|
||||
* view a tidy `omittedNote` ("5 lines / 234 chars") to render as a dim affordance.
|
||||
*/
|
||||
export function stripOmittedNote(text: string): { body: string; omittedNote?: string } {
|
||||
const s = (text ?? '').replace(/^\s+/, '')
|
||||
const match = s.match(/^\[showing verbose tail; omitted (.+?)\]\n/)
|
||||
if (!match) return { body: text ?? '' }
|
||||
return { body: s.slice(match[0].length), omittedNote: match[1] ?? '' }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -89,6 +89,38 @@ describe('App render (Phase 1, themed)', () => {
|
|||
expect(parts.some(p => p.type === 'text' && p.text === 'Listing files:')).toBe(true)
|
||||
})
|
||||
|
||||
test('a tool part shows its primary-arg preview + duration in the collapsed header (item 2)', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 't1', name: 'terminal', context: 'ls -la src' } })
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: {
|
||||
tool_id: 't1',
|
||||
name: 'terminal',
|
||||
args: { command: 'ls -la src' },
|
||||
duration_s: 0.3,
|
||||
result_text: 'alpha.txt\nbeta.txt'
|
||||
}
|
||||
})
|
||||
store.apply({ type: 'message.complete' })
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'ls -la src', width: 72, height: 16 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('terminal') // tool name
|
||||
expect(frame).toContain('ls -la src') // primary-arg preview (item 2 — args now visible)
|
||||
expect(frame).toContain('0.3s') // duration
|
||||
expect(frame).toContain('(2 lines)') // output line count (collapsed)
|
||||
})
|
||||
|
||||
test('an approval prompt replaces the composer (blocked) and renders the options', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
|
|
|
|||
|
|
@ -110,6 +110,30 @@ describe('session store — ordered parts (Phase 2b)', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('captures tool args: context→argsPreview, args→argsText, duration, omitted note (item 2)', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
store.apply({ type: 'tool.start', payload: { tool_id: 'a', name: 'terminal', context: 'ls -la src' } })
|
||||
store.apply({
|
||||
type: 'tool.complete',
|
||||
payload: {
|
||||
tool_id: 'a',
|
||||
name: 'terminal',
|
||||
args: { command: 'ls -la src' },
|
||||
duration_s: 0.34,
|
||||
result_text: '[showing verbose tail; omitted 3 lines / 90 chars]\nfile1\nfile2'
|
||||
}
|
||||
})
|
||||
const tool = store.state.messages.at(-1)!.parts![0]!
|
||||
if (tool.type !== 'tool') throw new Error('expected a tool part')
|
||||
expect(tool.argsPreview).toBe('ls -la src') // primary-arg preview shown in the header (NOT overwritten)
|
||||
expect(tool.argsText).toContain('"command"') // full args JSON for the expanded view
|
||||
expect(tool.duration).toBe(0.34)
|
||||
expect(tool.omittedNote).toBe('3 lines / 90 chars') // tidy note; raw label stripped
|
||||
expect(tool.resultText).toBe('file1\nfile2') // clean body (label peeled)
|
||||
expect(tool.lineCount).toBe(2)
|
||||
})
|
||||
|
||||
test('reasoning.delta accumulates into a reasoning part', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'message.start' })
|
||||
|
|
|
|||
|
|
@ -4,7 +4,25 @@
|
|||
*/
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { collapseToolOutput, stripToolEnvelope, truncate } from '../logic/toolOutput.ts'
|
||||
import { collapseToolOutput, stripOmittedNote, stripToolEnvelope, truncate } from '../logic/toolOutput.ts'
|
||||
|
||||
describe('stripOmittedNote (item 2 — peel the gateway verbose-tail label)', () => {
|
||||
test('extracts the lines/chars note and returns the clean body', () => {
|
||||
const { body, omittedNote } = stripOmittedNote('[showing verbose tail; omitted 5 lines / 234 chars]\nline one\nline two')
|
||||
expect(omittedNote).toBe('5 lines / 234 chars')
|
||||
expect(body).toBe('line one\nline two')
|
||||
})
|
||||
test('extracts a chars-only note', () => {
|
||||
const { body, omittedNote } = stripOmittedNote('[showing verbose tail; omitted 512 chars]\ntail body')
|
||||
expect(omittedNote).toBe('512 chars')
|
||||
expect(body).toBe('tail body')
|
||||
})
|
||||
test('passes through unlabeled output untouched', () => {
|
||||
const { body, omittedNote } = stripOmittedNote('normal output\nno prefix')
|
||||
expect(omittedNote).toBeUndefined()
|
||||
expect(body).toBe('normal output\nno prefix')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripToolEnvelope', () => {
|
||||
test('unwraps {output,exit_code} → output', () => {
|
||||
|
|
@ -21,6 +39,14 @@ describe('stripToolEnvelope', () => {
|
|||
expect(stripToolEnvelope('{not json')).toBe('{not json')
|
||||
expect(stripToolEnvelope('{"result":"no output key"}')).toBe('{"result":"no output key"}')
|
||||
})
|
||||
test('unwraps a TAIL-capped envelope fragment (item 2 — gateway serialises then tail-caps)', () => {
|
||||
// head was cut, tail keeps the envelope close → strip the trailing close
|
||||
expect(stripToolEnvelope('zsh\nzutty", "exit_code": 0, "error": null}')).toBe('zsh\nzutty')
|
||||
// head survived, tail cut → strip the leading {"output": "
|
||||
expect(stripToolEnvelope('{"output": "line1\nline2')).toBe('line1\nline2')
|
||||
// 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('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')
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
/**
|
||||
* ToolPart — one tool call, rendered COLLAPSED by default with a clear expand
|
||||
* affordance (spec §7; item 7 — "tools aren't collapsible and ugly-interlaced").
|
||||
* 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"):
|
||||
*
|
||||
* ▶ name summary/first-line (N lines) ← collapsed (default), one line
|
||||
* ▼ name ← expanded header
|
||||
* │ full output… ← left-bar block (capped)
|
||||
* ▶ 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)
|
||||
*
|
||||
* A `▶`/`▼` glyph marks expandable tools; clicking the header toggles it (mouse).
|
||||
* Running tools show `name …`; single-line/erroring tools render inline (no
|
||||
* expand). `resultText` is already `{output,exit_code}`-envelope-stripped by the
|
||||
* store. Fully themed (no hardcoded styles).
|
||||
* `▶`/`▼` 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}.
|
||||
*/
|
||||
import { type ToolPartState } from '../logic/store.ts'
|
||||
import { useTerminalDimensions } from '@opentui/solid'
|
||||
|
|
@ -21,6 +23,16 @@ import { useTheme } from './theme.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`
|
||||
if (s < 60) return `${Math.round(s)}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const r = Math.round(s % 60)
|
||||
return r ? `${m}m ${r}s` : `${m}m`
|
||||
}
|
||||
|
||||
export function ToolPart(props: { part: ToolPartState }) {
|
||||
const theme = useTheme()
|
||||
|
|
@ -31,18 +43,52 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
|
||||
const lines = () => (result() ? result().split('\n') : [])
|
||||
const running = () => props.part.state === 'running'
|
||||
const multiline = () => lines().length > 1
|
||||
const collapsible = () => !running() && multiline()
|
||||
// Collapsed gist: the explicit summary, else the first output line, else nothing.
|
||||
const summary = () => (props.part.error ? `✗ ${props.part.error}` : (props.part.summary || lines()[0] || ''))
|
||||
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
|
||||
if (e.length === 1) {
|
||||
const v = e[0]![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))
|
||||
|
||||
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
||||
const headColor = () => (props.part.error ? theme().color.error : theme().color.muted)
|
||||
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: 'column', flexShrink: 0, marginTop: 1 }}>
|
||||
{/* header — clickable to toggle when there's expandable output */}
|
||||
{/* header — clickable to toggle when there's expandable output/args */}
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0 }} onMouseDown={() => collapsible() && setExpanded(e => !e)}>
|
||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||
<text selectable={false}>
|
||||
|
|
@ -55,28 +101,67 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
<Show when={running()}>
|
||||
<span style={{ fg: theme().color.muted }}> …</span>
|
||||
</Show>
|
||||
<Show when={!running() && summary()}>
|
||||
<Show when={!running() && subtitle()}>
|
||||
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
|
||||
{` ${truncate(summary(), Math.max(1, bodyWidth() - props.part.name.length - 2))}`}
|
||||
{` ${truncate(subtitle(), subWidth())}`}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={collapsible() && !expanded()}>
|
||||
<Show when={!running() && props.part.duration !== undefined}>
|
||||
<span style={{ fg: theme().color.muted }}>{` · ${fmtDuration(props.part.duration!)}`}</span>
|
||||
</Show>
|
||||
<Show when={collapsible() && !expanded() && lines().length > 1}>
|
||||
<span style={{ fg: theme().color.muted }}>{` (${lines().length} lines)`}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
{/* expanded body — left-bar block of the (capped) output */}
|
||||
|
||||
{/* 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). */}
|
||||
<Show when={collapsible() && expanded()}>
|
||||
<box style={{ flexDirection: 'row', flexGrow: 1, minWidth: 0, marginLeft: GUTTER }}>
|
||||
<box
|
||||
style={{
|
||||
backgroundColor: props.part.error ? theme().color.error : theme().color.border,
|
||||
flexShrink: 0,
|
||||
width: 1
|
||||
}}
|
||||
/>
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, paddingLeft: 1 }}>
|
||||
<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()}>
|
||||
<text>
|
||||
<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>
|
||||
<span style={{ fg: theme().color.muted }}>{truncate(line, bodyWidth() - 2)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<For each={argEntries().slice(0, ARGS_MAX)}>
|
||||
{([k, v]) => (
|
||||
<text>
|
||||
<span style={{ fg: theme().color.muted }}>{truncate(argLine(k, v), bodyWidth() - 2)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
<Show when={argEntries().length > ARGS_MAX}>
|
||||
<text>
|
||||
<span style={{ fg: theme().color.accent }}>{`… +${argEntries().length - ARGS_MAX} more`}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={showArgs() && hasOutput()}>
|
||||
<text>
|
||||
<span style={{ fg: theme().color.label }}>output</span>
|
||||
</text>
|
||||
</Show>
|
||||
<For each={body().lines}>
|
||||
{line => (
|
||||
<text>
|
||||
|
|
@ -84,7 +169,12 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
</text>
|
||||
)}
|
||||
</For>
|
||||
<Show when={body().hiddenLines > 0}>
|
||||
<Show when={props.part.omittedNote}>
|
||||
<text>
|
||||
<span style={{ fg: theme().color.muted }}>{`… omitted ${props.part.omittedNote}`}</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={body().hiddenLines > 0 && !props.part.omittedNote}>
|
||||
<text>
|
||||
<span style={{ fg: theme().color.accent }}>{`… +${body().hiddenLines} more lines`}</span>
|
||||
</text>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue