opentui(v6): tool-name emphasis, thought styling, HERMES_TUI_TOOL_OUTPUT_LINES

This commit is contained in:
alt-glitch 2026-06-10 19:31:45 +05:30
parent 6e62489d9e
commit 0dafcdd9e3
8 changed files with 296 additions and 18 deletions

View file

@ -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() ?? '') !== ''
}

View file

@ -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

View file

@ -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 (todays 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)
})
})

View file

@ -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

View file

@ -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>

View file

@ -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. */}

View file

@ -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:

View file

@ -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 */}