mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): tool-name emphasis, thought styling, HERMES_TUI_TOOL_OUTPUT_LINES
This commit is contained in:
parent
6e62489d9e
commit
0dafcdd9e3
8 changed files with 296 additions and 18 deletions
|
|
@ -14,3 +14,29 @@ export function envFlag(value: string | undefined, fallback: boolean): boolean {
|
|||
if (FALSE_RE.test(v)) return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Default cap on output lines shown by an EXPANDED tool body. */
|
||||
export const TOOL_OUTPUT_LINES_DEFAULT = 200
|
||||
|
||||
/**
|
||||
* Parse `HERMES_TUI_TOOL_OUTPUT_LINES` (a TUI-only env var — deliberately NOT
|
||||
* a config.yaml knob): how many output lines an expanded tool body shows.
|
||||
* Unset/garbage → 200 (the long-standing default); a positive integer → that
|
||||
* cap; `0` → Infinity (UNLIMITED — show the entire output).
|
||||
*/
|
||||
export function envOutputLines(value: string | undefined): number {
|
||||
const v = value?.trim() ?? ''
|
||||
if (!/^\d+$/.test(v)) return TOOL_OUTPUT_LINES_DEFAULT
|
||||
const n = Number.parseInt(v, 10)
|
||||
return n === 0 ? Number.POSITIVE_INFINITY : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `HERMES_TUI_TOOL_OUTPUT_LINES` was EXPLICITLY set (any non-empty
|
||||
* value, even an unparseable one). When it is, the store prefers the
|
||||
* always-full raw `result` over a gateway tail-capped `result_text` — see
|
||||
* store.ts tool.complete.
|
||||
*/
|
||||
export function envOutputLinesSet(value: string | undefined): boolean {
|
||||
return (value?.trim() ?? '') !== ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type SessionInfoPatchDecoded
|
||||
} from '../boundary/schema/SessionInfo.ts'
|
||||
import { diffStats, type DiffStats } from './diff.ts'
|
||||
import { envOutputLinesSet } from './env.ts'
|
||||
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts'
|
||||
|
||||
|
|
@ -679,9 +680,26 @@ export function createSessionStore() {
|
|||
// (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(
|
||||
let { body: rawBody, omittedNote } = stripOmittedNote(
|
||||
readStr(event.payload, 'result_text') ?? stringifyResult(event.payload['result']) ?? summary ?? ''
|
||||
)
|
||||
// HERMES_TUI_TOOL_OUTPUT_LINES set → the user explicitly controls how much
|
||||
// output the expanded view shows, but a gateway-capped `result_text`
|
||||
// (omittedNote) is only a TAIL — substituting the always-full raw `result`
|
||||
// is the only way flag=0 (unlimited) can actually show everything. Same
|
||||
// display pipeline (envelope strip) — and the same raw-result redaction
|
||||
// tradeoff — as the existing non-verbose stringifyResult fallback above.
|
||||
// The omitted note no longer applies to the full body; "+N more lines"
|
||||
// (view-side) stays honest for caps below the full length. File-edit JSON
|
||||
// results stay parseable, so fileTool's diffOutputPlan still suppresses
|
||||
// the diff echo. The redacted-argsText precedence is untouched.
|
||||
if (omittedNote && envOutputLinesSet(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)) {
|
||||
const full = stringifyResult(event.payload['result'])
|
||||
if (full !== undefined) {
|
||||
rawBody = full
|
||||
omittedNote = undefined
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
import { envFlag, envOutputLines, envOutputLinesSet, TOOL_OUTPUT_LINES_DEFAULT } from '../logic/env.ts'
|
||||
|
||||
describe('envFlag', () => {
|
||||
test('recognizes truthy values regardless of case/whitespace', () => {
|
||||
|
|
@ -29,3 +29,39 @@ describe('envFlag', () => {
|
|||
expect(envFlag('enabled', false)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('envOutputLines (HERMES_TUI_TOOL_OUTPUT_LINES)', () => {
|
||||
test('unset → the 200-line default (today’s behavior)', () => {
|
||||
expect(TOOL_OUTPUT_LINES_DEFAULT).toBe(200)
|
||||
expect(envOutputLines(undefined)).toBe(200)
|
||||
expect(envOutputLines('')).toBe(200)
|
||||
expect(envOutputLines(' ')).toBe(200)
|
||||
})
|
||||
|
||||
test('a positive integer → that cap (whitespace-tolerant)', () => {
|
||||
expect(envOutputLines('50')).toBe(50)
|
||||
expect(envOutputLines(' 50 ')).toBe(50)
|
||||
expect(envOutputLines('1')).toBe(1)
|
||||
expect(envOutputLines('1000')).toBe(1000)
|
||||
})
|
||||
|
||||
test('"0" → Infinity (UNLIMITED — show the entire output)', () => {
|
||||
expect(envOutputLines('0')).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
|
||||
test('garbage → the 200-line default', () => {
|
||||
expect(envOutputLines('unlimited')).toBe(200)
|
||||
expect(envOutputLines('-5')).toBe(200)
|
||||
expect(envOutputLines('1.5')).toBe(200)
|
||||
expect(envOutputLines('50 lines')).toBe(200)
|
||||
})
|
||||
|
||||
test('envOutputLinesSet: set means any non-empty value, even garbage', () => {
|
||||
expect(envOutputLinesSet(undefined)).toBe(false)
|
||||
expect(envOutputLinesSet('')).toBe(false)
|
||||
expect(envOutputLinesSet(' ')).toBe(false)
|
||||
expect(envOutputLinesSet('0')).toBe(true)
|
||||
expect(envOutputLinesSet('50')).toBe(true)
|
||||
expect(envOutputLinesSet('garbage')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@
|
|||
import { describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { createSessionStore, type ToolPartState } from '../logic/store.ts'
|
||||
import { DEFAULT_THEME } from '../logic/theme.ts'
|
||||
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 { diffOutputPlan, FileToolBody } from '../view/tools/fileTool.tsx'
|
||||
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
||||
|
|
@ -565,6 +568,150 @@ describe('tool lifecycle states — running / done / failed (Epic 2.5)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only env var)', () => {
|
||||
const ENV = 'HERMES_TUI_TOOL_OUTPUT_LINES'
|
||||
|
||||
/** Run `fn` with the flag set to `value` (undefined → unset), restoring after. */
|
||||
async function withFlag(value: string | undefined, fn: () => Promise<void> | void) {
|
||||
const prev = process.env[ENV]
|
||||
if (value === undefined) delete process.env[ENV]
|
||||
else process.env[ENV] = value
|
||||
try {
|
||||
await fn()
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[ENV]
|
||||
else process.env[ENV] = prev
|
||||
}
|
||||
}
|
||||
|
||||
test('flag=0 (unlimited): a 250-line output renders ALL lines with NO "+N more lines" note', async () => {
|
||||
await withFlag('0', async () => {
|
||||
const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`)
|
||||
const part: ToolPartState = {
|
||||
type: 'tool',
|
||||
id: 'u1',
|
||||
name: 'terminal',
|
||||
state: 'complete',
|
||||
args: { command: 'seq 1 250' },
|
||||
resultText: lines.join('\n')
|
||||
}
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider>
|
||||
<BashToolBody part={part} width={70} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 80, height: 260 }
|
||||
)
|
||||
try {
|
||||
const frame = await probe.waitForFrame(f => f.includes('row-250'))
|
||||
expect(frame).toContain('row-001')
|
||||
expect(frame).toContain('row-200')
|
||||
expect(frame).toContain('row-201') // beyond the old 200-line cap…
|
||||
expect(frame).toContain('row-250') // …down to the very last line
|
||||
expect(frame).not.toContain('more lines') // and no truncation note
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('flag unset: the same 250-line output still caps at 200 + the honest note (default unchanged)', async () => {
|
||||
await withFlag(undefined, async () => {
|
||||
const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`)
|
||||
const part: ToolPartState = {
|
||||
type: 'tool',
|
||||
id: 'u2',
|
||||
name: 'terminal',
|
||||
state: 'complete',
|
||||
args: { command: 'seq 1 250' },
|
||||
resultText: lines.join('\n')
|
||||
}
|
||||
const probe = await renderProbe(
|
||||
() => (
|
||||
<ThemeProvider>
|
||||
<BashToolBody part={part} width={70} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width: 80, height: 260 }
|
||||
)
|
||||
try {
|
||||
const frame = await probe.waitForFrame(f => f.includes('+50 more lines'))
|
||||
expect(frame).toContain('row-200')
|
||||
expect(frame).not.toContain('row-201')
|
||||
expect(frame).toContain('… +50 more lines')
|
||||
} finally {
|
||||
probe.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('store: flag set + gateway tail-cap (omittedNote) + full raw result → body derived from the raw result', async () => {
|
||||
const full = Array.from({ length: 250 }, (_, i) => `full-${i + 1}`).join('\n')
|
||||
const cappedTail = ['full-241', 'full-242', 'full-243'].join('\n') // what the gateway kept
|
||||
const payload = {
|
||||
tool_id: 'p1',
|
||||
name: 'terminal',
|
||||
args: { command: 'seq 1 250' },
|
||||
result_text: `[showing verbose tail; omitted 240 lines / 2000 chars]\n${cappedTail}`,
|
||||
result: { output: full, exit_code: 0 }
|
||||
}
|
||||
const partOf = (store: Store) =>
|
||||
store.state.messages[store.state.messages.length - 1]?.parts?.find(
|
||||
(p): p is ToolPartState => p.type === 'tool' && p.id === 'p1'
|
||||
)
|
||||
|
||||
await withFlag('0', () => {
|
||||
const store = createSessionStore()
|
||||
seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload)
|
||||
const part = partOf(store)
|
||||
// the FULL raw result wins: longer than the capped tail, head included
|
||||
expect(part?.resultText).toContain('full-1\n')
|
||||
expect(part?.resultText).toContain('full-250')
|
||||
expect(part?.omittedNote).toBeUndefined() // the note no longer applies
|
||||
})
|
||||
|
||||
// flag UNSET → today's behavior: the gateway tail + the tidy omitted note
|
||||
await withFlag(undefined, () => {
|
||||
const store = createSessionStore()
|
||||
seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload)
|
||||
const part = partOf(store)
|
||||
expect(part?.resultText).toBe(cappedTail)
|
||||
expect(part?.resultText).not.toContain('full-1\n')
|
||||
expect(part?.omittedNote).toBe('240 lines / 2000 chars')
|
||||
})
|
||||
|
||||
// flag set but NO raw result on the wire → keep the tail + note (no crash)
|
||||
await withFlag('0', () => {
|
||||
const store = createSessionStore()
|
||||
const withoutRaw: Record<string, unknown> = { ...payload }
|
||||
delete withoutRaw['result']
|
||||
seedTool(store, { tool_id: 'p1', name: 'terminal' }, withoutRaw)
|
||||
const part = partOf(store)
|
||||
expect(part?.resultText).toBe(cappedTail)
|
||||
expect(part?.omittedNote).toBe('240 lines / 2000 chars')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-name emphasis + thought styling (feedback: undifferentiated muted rows)', () => {
|
||||
const color = DEFAULT_THEME.color
|
||||
|
||||
test('toolNameStyle: settled name is PRIMARY (text color + bold); subtitle stays muted by the shell', () => {
|
||||
expect(toolNameStyle({ failed: false, running: false }, color)).toEqual({ bold: true, fg: color.text })
|
||||
expect(color.text).not.toBe(color.muted) // the emphasis is real, not a no-op
|
||||
})
|
||||
|
||||
test('toolNameStyle: failed keeps the error coloring (it wins), running keeps its muted treatment', () => {
|
||||
expect(toolNameStyle({ failed: true, running: false }, color)).toEqual({ bold: true, fg: color.error })
|
||||
expect(toolNameStyle({ failed: false, running: true }, color)).toEqual({ bold: false, fg: color.muted })
|
||||
})
|
||||
|
||||
test('reasoningLabelStyle: muted + ITALIC — a different KIND of row than a tool, never louder', () => {
|
||||
expect(reasoningLabelStyle(color)).toEqual({ fg: color.muted, italic: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('redaction precedence — gateway args_text wins over raw args (security)', () => {
|
||||
// The gateway redacts verbose `args_text` (server.py _tool_args_text) but
|
||||
// sends the raw `args` dict on tool.complete UNREDACTED. structuredArgs must
|
||||
|
|
|
|||
|
|
@ -13,12 +13,25 @@
|
|||
*/
|
||||
import { createMemo, createSignal, Show } from 'solid-js'
|
||||
|
||||
import type { ThemeColors } from '../logic/theme.ts'
|
||||
import { Markdown } from './markdown.tsx'
|
||||
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
||||
const GUTTER = 2
|
||||
|
||||
/**
|
||||
* Header label style — reasoning is the MOST secondary tier, so the word
|
||||
* Thinking/Thought + its title preview stay muted but render ITALIC: a
|
||||
* `▶ Thought` row reads as a different KIND of row than a tool at a glance
|
||||
* (tools carry a bold text-colored name; reasoning stays quiet, just
|
||||
* recognizably distinct). Exported so tests can pin the selection logic
|
||||
* (char frames carry no color/attribute info).
|
||||
*/
|
||||
export function reasoningLabelStyle(color: ThemeColors): { fg: string; italic: boolean } {
|
||||
return { fg: color.muted, italic: true }
|
||||
}
|
||||
|
||||
/** Split a leading `**Title**\n\n body` into {title, body} (opencode reasoningSummary). */
|
||||
function reasoningSummary(text: string): { title?: string; body: string } {
|
||||
const s = (text ?? '').replace('[REDACTED]', '').trim()
|
||||
|
|
@ -51,11 +64,12 @@ export function ReasoningPart(props: { text: string; streaming?: boolean }) {
|
|||
— chrome, not the reasoning body — so a free-form drag yields only
|
||||
the markdown body below, not the section label (item 4). */}
|
||||
<text selectable={false}>
|
||||
{/* accent chevron marks it; muted label keeps reasoning in the dim,
|
||||
secondary tier alongside tool calls (Ink hierarchy). */}
|
||||
<span style={{ fg: theme().color.muted }}>{label()}</span>
|
||||
{/* accent chevron marks it; muted ITALIC label keeps reasoning the
|
||||
most secondary tier AND visibly a different kind of row than a
|
||||
tool (bold name) — see reasoningLabelStyle. */}
|
||||
<span style={reasoningLabelStyle(theme().color)}>{label()}</span>
|
||||
<Show when={summary().title}>
|
||||
<span style={{ fg: theme().color.muted }}>{`: ${summary().title}`}</span>
|
||||
<span style={reasoningLabelStyle(theme().color)}>{`: ${summary().title}`}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
* copies only the expanded body content. Fully themed (no hardcoded styles).
|
||||
*/
|
||||
import { type ToolPartState } from '../logic/store.ts'
|
||||
import type { ThemeColors } from '../logic/theme.ts'
|
||||
import { useDimensions } from './dimensions.tsx'
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
|
||||
|
|
@ -49,6 +50,24 @@ function fmtElapsed(s: number): string {
|
|||
return r ? `${m}m ${r}s` : `${m}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Header tool-NAME style — the name is the PRIMARY cue for what a settled tool
|
||||
* IS, so it renders in the primary text color + BOLD (the transcript otherwise
|
||||
* reads as undifferentiated muted rows). The failed state's error coloring
|
||||
* wins (still bold — failures should be unmistakable alongside the ✗ glyph);
|
||||
* a running part keeps its current muted treatment (the ⚡ glyph + live
|
||||
* elapsed already carry the running signal). Exported so tests can pin the
|
||||
* selection logic (char frames carry no color/attribute info).
|
||||
*/
|
||||
export function toolNameStyle(
|
||||
state: { failed: boolean; running: boolean },
|
||||
color: ThemeColors
|
||||
): { fg: string; bold: boolean } {
|
||||
if (state.failed) return { bold: true, fg: color.error }
|
||||
if (state.running) return { bold: false, fg: color.muted }
|
||||
return { bold: true, fg: color.text }
|
||||
}
|
||||
|
||||
/**
|
||||
* Live ` · 12s` elapsed for a RUNNING part. Mounted only under the running
|
||||
* `<Show>`, so its useElapsedTick subscription starts/stops the SHARED 1s
|
||||
|
|
@ -92,8 +111,9 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
// expandable body. `error` only lands on tool.complete, so running stays ⚡.
|
||||
const failed = () => !running() && Boolean(props.part.error)
|
||||
const headGlyph = () => (failed() ? '✗' : collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
||||
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
|
||||
// as the dim, secondary tier below the bright assistant answer (Ink hierarchy).
|
||||
// accent glyph MARKS the tool (draws the eye); the NAME is primary (bold text
|
||||
// via toolNameStyle) so WHAT the tool is reads at a glance; subtitle/metadata
|
||||
// stay muted — the secondary tier below the bright assistant answer.
|
||||
const headColor = () => (failed() ? theme().color.error : theme().color.accent)
|
||||
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
|
||||
|
||||
|
|
@ -114,7 +134,11 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
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>
|
||||
{/* the NAME is the primary cue (text + bold; error when failed;
|
||||
muted while running) — see toolNameStyle. Subtitle stays muted. */}
|
||||
<span style={toolNameStyle({ failed: failed(), running: running() }, theme().color)}>
|
||||
{props.part.name}
|
||||
</span>
|
||||
{/* subtitle shows while running too (the gateway argsPreview — e.g.
|
||||
the command being executed) so a running tool reads `⚡ terminal
|
||||
sleep 8 · 12s`, Ink parity. */}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
* `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
|
||||
* to the expanded-lines cap (HERMES_TUI_TOOL_OUTPUT_LINES; default 200, 0 →
|
||||
* unlimited) with the honest omitted / "+N more lines" notes from
|
||||
* `logic/toolOutput.ts` (via the shared ToolOutputBlock).
|
||||
*
|
||||
* Arg keys verified against the Python tool schemas:
|
||||
|
|
|
|||
|
|
@ -8,19 +8,31 @@
|
|||
* `(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).
|
||||
* envelope-stripped text, capped to the expanded-lines cap (the
|
||||
* HERMES_TUI_TOOL_OUTPUT_LINES env var; default 200, 0 → unlimited) 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 { 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 (a sane cap to avoid huge renders). */
|
||||
export const EXPANDED_MAX = 200
|
||||
/**
|
||||
* Max output lines shown when expanded — `HERMES_TUI_TOOL_OUTPUT_LINES` (a
|
||||
* TUI-only env var, not a config.yaml knob): unset/garbage → 200 (a sane cap
|
||||
* to avoid huge renders), positive int → that cap, `0` → Infinity (unlimited).
|
||||
* Memory note: this is safe to lift — tool rows mount collapsed (no body), 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
|
||||
|
||||
|
|
@ -89,13 +101,13 @@ export function defaultSubtitle(part: ToolPartState): string {
|
|||
|
||||
/**
|
||||
* 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).
|
||||
* 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(), EXPANDED_MAX, props.width))
|
||||
const body = createMemo(() => collapseToolOutput(result(), expandedMaxLines(), props.width))
|
||||
return (
|
||||
<Show when={result()}>
|
||||
{/* section label — chrome, not content */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue