From 0dafcdd9e3643fdcce5e4e4bb41f7ed3e12a5de5 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 19:31:45 +0530 Subject: [PATCH] opentui(v6): tool-name emphasis, thought styling, HERMES_TUI_TOOL_OUTPUT_LINES --- ui-opentui/src/logic/env.ts | 26 ++++ ui-opentui/src/logic/store.ts | 20 ++- ui-opentui/src/test/env.test.ts | 38 +++++- ui-opentui/src/test/tools.test.tsx | 147 ++++++++++++++++++++++ ui-opentui/src/view/reasoningPart.tsx | 22 +++- ui-opentui/src/view/toolPart.tsx | 30 ++++- ui-opentui/src/view/tools/bashTool.tsx | 3 +- ui-opentui/src/view/tools/defaultTool.tsx | 28 +++-- 8 files changed, 296 insertions(+), 18 deletions(-) diff --git a/ui-opentui/src/logic/env.ts b/ui-opentui/src/logic/env.ts index 294e5c69ef2..8b19607e540 100644 --- a/ui-opentui/src/logic/env.ts +++ b/ui-opentui/src/logic/env.ts @@ -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() ?? '') !== '' +} diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index cd430469949..bebde04741e 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -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 diff --git a/ui-opentui/src/test/env.test.ts b/ui-opentui/src/test/env.test.ts index 18e4e8d4237..1d92fb386d7 100644 --- a/ui-opentui/src/test/env.test.ts +++ b/ui-opentui/src/test/env.test.ts @@ -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) + }) +}) diff --git a/ui-opentui/src/test/tools.test.tsx b/ui-opentui/src/test/tools.test.tsx index c2b44e39884..25663a7926f 100644 --- a/ui-opentui/src/test/tools.test.tsx +++ b/ui-opentui/src/test/tools.test.tsx @@ -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) { + 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( + () => ( + + + + ), + { 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( + () => ( + + + + ), + { 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 = { ...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 diff --git a/ui-opentui/src/view/reasoningPart.tsx b/ui-opentui/src/view/reasoningPart.tsx index 22b6bfa8d4b..790e0d4b1d3 100644 --- a/ui-opentui/src/view/reasoningPart.tsx +++ b/ui-opentui/src/view/reasoningPart.tsx @@ -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). */} - {/* accent chevron marks it; muted label keeps reasoning in the dim, - secondary tier alongside tool calls (Ink hierarchy). */} - {label()} + {/* 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. */} + {label()} - {`: ${summary().title}`} + {`: ${summary().title}`} diff --git a/ui-opentui/src/view/toolPart.tsx b/ui-opentui/src/view/toolPart.tsx index 0499a3a299b..f830438c725 100644 --- a/ui-opentui/src/view/toolPart.tsx +++ b/ui-opentui/src/view/toolPart.tsx @@ -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 * ``, 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. */} - {props.part.name} + {/* the NAME is the primary cue (text + bold; error when failed; + muted while running) — see toolNameStyle. Subtitle stays muted. */} + + {props.part.name} + {/* 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. */} diff --git a/ui-opentui/src/view/tools/bashTool.tsx b/ui-opentui/src/view/tools/bashTool.tsx index a24d21ece9d..45cd5ec95cf 100644 --- a/ui-opentui/src/view/tools/bashTool.tsx +++ b/ui-opentui/src/view/tools/bashTool.tsx @@ -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: diff --git a/ui-opentui/src/view/tools/defaultTool.tsx b/ui-opentui/src/view/tools/defaultTool.tsx index 8ea163ad9b9..480e3bcb1a2 100644 --- a/ui-opentui/src/view/tools/defaultTool.tsx +++ b/ui-opentui/src/view/tools/defaultTool.tsx @@ -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 ( {/* section label — chrome, not content */}