diff --git a/ui-opentui/src/logic/env.ts b/ui-opentui/src/logic/env.ts index 8b19607e540..6c86a8ca9b9 100644 --- a/ui-opentui/src/logic/env.ts +++ b/ui-opentui/src/logic/env.ts @@ -15,28 +15,29 @@ export function envFlag(value: string | undefined, fallback: boolean): boolean { 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). + * UNSET → Infinity (UNLIMITED — expanded tool output is uncapped by default; + * setting the var is how you RESTORE a cap, e.g. `=200`). A positive integer + * → that cap. `0` → Infinity too (back-compat: it was the old opt-in + * "unlimited" value). Garbage → Infinity (unrecognized ≙ no cap asked for — + * the semantic is "cap only when the user asked for one"). */ export function envOutputLines(value: string | undefined): number { const v = value?.trim() ?? '' - if (!/^\d+$/.test(v)) return TOOL_OUTPUT_LINES_DEFAULT + if (!/^\d+$/.test(v)) return Number.POSITIVE_INFINITY 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. + * Whether NO line cap applies (unset / `0` / unparseable). When unlimited, + * the store prefers the always-full raw `result` over a gateway tail-capped + * `result_text` — an "unlimited" view of a tail would still be missing its + * head — see store.ts tool.complete. With an explicit finite cap the gateway + * tail (+ honest omitted note) is kept: the user asked for a bounded view. */ -export function envOutputLinesSet(value: string | undefined): boolean { - return (value?.trim() ?? '') !== '' +export function envOutputUnlimited(value: string | undefined): boolean { + return envOutputLines(value) === Number.POSITIVE_INFINITY } diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 5d031abd696..77dcfec6202 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -23,7 +23,7 @@ import { } from '../boundary/schema/SessionInfo.ts' import type { DetailsMode } from './details.ts' import { diffStats, type DiffStats } from './diff.ts' -import { envOutputLinesSet } from './env.ts' +import { envOutputUnlimited } from './env.ts' import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts' import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.ts' @@ -744,17 +744,18 @@ export function createSessionStore() { 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 + // The view cap is UNLIMITED (HERMES_TUI_TOOL_OUTPUT_LINES unset/0 — the + // default), but a gateway-capped `result_text` (omittedNote) is only a + // TAIL — substituting the always-full raw `result` is the only way the + // uncapped view 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. With an explicit FINITE cap the + // gateway tail + note are kept (the user asked for a bounded view; the + // view-side "+N more lines" stays honest below that). 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)) { + if (omittedNote && envOutputUnlimited(process.env.HERMES_TUI_TOOL_OUTPUT_LINES)) { const full = stringifyResult(event.payload['result']) if (full !== undefined) { rawBody = full diff --git a/ui-opentui/src/test/env.test.ts b/ui-opentui/src/test/env.test.ts index 1d92fb386d7..4ecb5c65ad2 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, envOutputLines, envOutputLinesSet, TOOL_OUTPUT_LINES_DEFAULT } from '../logic/env.ts' +import { envFlag, envOutputLines, envOutputUnlimited } from '../logic/env.ts' describe('envFlag', () => { test('recognizes truthy values regardless of case/whitespace', () => { @@ -31,37 +31,38 @@ describe('envFlag', () => { }) 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('unset → Infinity (UNLIMITED by default — the env var RESTORES a cap)', () => { + expect(envOutputLines(undefined)).toBe(Number.POSITIVE_INFINITY) + expect(envOutputLines('')).toBe(Number.POSITIVE_INFINITY) + expect(envOutputLines(' ')).toBe(Number.POSITIVE_INFINITY) }) 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('200')).toBe(200) expect(envOutputLines('1000')).toBe(1000) }) - test('"0" → Infinity (UNLIMITED — show the entire output)', () => { + test('"0" → Infinity too (back-compat with the old opt-in "unlimited" value)', () => { 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('garbage → Infinity (unrecognized ≙ no cap asked for)', () => { + expect(envOutputLines('unlimited')).toBe(Number.POSITIVE_INFINITY) + expect(envOutputLines('-5')).toBe(Number.POSITIVE_INFINITY) + expect(envOutputLines('1.5')).toBe(Number.POSITIVE_INFINITY) + expect(envOutputLines('50 lines')).toBe(Number.POSITIVE_INFINITY) }) - 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) + test('envOutputUnlimited: true unless an explicit finite cap was asked for', () => { + expect(envOutputUnlimited(undefined)).toBe(true) + expect(envOutputUnlimited('')).toBe(true) + expect(envOutputUnlimited(' ')).toBe(true) + expect(envOutputUnlimited('0')).toBe(true) + expect(envOutputUnlimited('garbage')).toBe(true) + expect(envOutputUnlimited('50')).toBe(false) + expect(envOutputUnlimited('200')).toBe(false) }) }) diff --git a/ui-opentui/src/test/tools.test.tsx b/ui-opentui/src/test/tools.test.tsx index 7526cb5d2e1..6ea7a9a8260 100644 --- a/ui-opentui/src/test/tools.test.tsx +++ b/ui-opentui/src/test/tools.test.tsx @@ -4,7 +4,8 @@ * acceptance gate asserts NO raw JSON syntax (`{"` / `":`) ever reaches the * frame for tool parts, collapsed or expanded — delegate_task carries the * Ink-parity "(/agents to monitor)" hint, and the bash renderer shows the - * command verbatim collapsed + the full (EXPANDED_MAX-capped) output expanded. + * command verbatim collapsed + the FULL output expanded (uncapped by default; + * HERMES_TUI_TOOL_OUTPUT_LINES restores a cap). * Expansion goes through the REAL mouse path: mockMouse clicks the header row * (found by scanning the frame). The long-output cap is asserted at the Body * level (a tall frame would otherwise hide the trailing note). @@ -180,7 +181,10 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => { } }) - test('long output is capped to EXPANDED_MAX with an honest "+N more lines" note', async () => { + test('long output with an explicit =200 cap restored gets the honest "+N more lines" note', async () => { + // Output is UNCAPPED by default now — restore the old 200-line cap explicitly. + const prev = process.env.HERMES_TUI_TOOL_OUTPUT_LINES + process.env.HERMES_TUI_TOOL_OUTPUT_LINES = '200' const lines = Array.from({ length: 250 }, (_, i) => `line-${String(i + 1).padStart(3, '0')}`) const part: ToolPartState = { type: 'tool', @@ -203,11 +207,13 @@ describe('bash tool renderer — command + full output (Epic 2.4)', () => { const frame = await probe.waitForFrame(f => f.includes('+50 more lines')) expect(frame).toContain('$ for i in range(250): print(i)') expect(frame).toContain('line-001') // the cap keeps the HEAD of the output - expect(frame).toContain('line-200') // …up to EXPANDED_MAX + expect(frame).toContain('line-200') // …up to the restored cap expect(frame).not.toContain('line-201') // the rest is honestly elided expect(frame).toContain('… +50 more lines') } finally { probe.destroy() + if (prev === undefined) delete process.env.HERMES_TUI_TOOL_OUTPUT_LINES + else process.env.HERMES_TUI_TOOL_OUTPUT_LINES = prev } }) @@ -620,7 +626,7 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en }) }) - test('flag unset: the same 250-line output still caps at 200 + the honest note (default unchanged)', async () => { + test('flag unset: the same 250-line output renders ALL lines (UNLIMITED is the default now)', async () => { await withFlag(undefined, async () => { const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`) const part: ToolPartState = { @@ -640,17 +646,48 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en { 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') + const frame = await probe.waitForFrame(f => f.includes('row-250')) + expect(frame).toContain('row-001') + expect(frame).toContain('row-201') // beyond the old 200-line default… + 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('store: flag set + gateway tail-cap (omittedNote) + full raw result → body derived from the raw result', async () => { + test('flag=50: an explicit cap is RESTORED — 50 lines + the honest "+200 more lines" note', async () => { + await withFlag('50', async () => { + const lines = Array.from({ length: 250 }, (_, i) => `row-${String(i + 1).padStart(3, '0')}`) + const part: ToolPartState = { + type: 'tool', + id: 'u3', + 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('+200 more lines')) + expect(frame).toContain('row-050') + expect(frame).not.toContain('row-051') + expect(frame).toContain('… +200 more lines') + } finally { + probe.destroy() + } + }) + }) + + test('store: unlimited cap + 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 = { @@ -675,8 +712,19 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en expect(part?.omittedNote).toBeUndefined() // the note no longer applies }) - // flag UNSET → today's behavior: the gateway tail + the tidy omitted note + // flag UNSET → unlimited is the DEFAULT now: the full raw result wins too await withFlag(undefined, () => { + const store = createSessionStore() + seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload) + const part = partOf(store) + expect(part?.resultText).toContain('full-1\n') + expect(part?.resultText).toContain('full-250') + expect(part?.omittedNote).toBeUndefined() + }) + + // an explicit FINITE cap (=50) → the user asked for a bounded view: keep + // the gateway tail + the honest omitted note (the view caps further) + await withFlag('50', () => { const store = createSessionStore() seedTool(store, { tool_id: 'p1', name: 'terminal' }, payload) const part = partOf(store) @@ -685,7 +733,7 @@ describe('HERMES_TUI_TOOL_OUTPUT_LINES — expanded-output line cap (TUI-only en expect(part?.omittedNote).toBe('240 lines / 2000 chars') }) - // flag set but NO raw result on the wire → keep the tail + note (no crash) + // unlimited but NO raw result on the wire → keep the tail + note (no crash) await withFlag('0', () => { const store = createSessionStore() const withoutRaw: Record = { ...payload } diff --git a/ui-opentui/src/view/tools/bashTool.tsx b/ui-opentui/src/view/tools/bashTool.tsx index 45cd5ec95cf..33733c850fb 100644 --- a/ui-opentui/src/view/tools/bashTool.tsx +++ b/ui-opentui/src/view/tools/bashTool.tsx @@ -2,9 +2,9 @@ * BashTool — renderer for the shell-ish tools `terminal`, `execute_code` and * `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-lines cap (HERMES_TUI_TOOL_OUTPUT_LINES; default 200, 0 → - * unlimited) with the honest omitted / "+N more lines" notes from + * the full command (`$ `-prefixed, multi-line safe) and the FULL output — + * uncapped by default (HERMES_TUI_TOOL_OUTPUT_LINES restores a cap, e.g. + * `=200`) 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 480e3bcb1a2..7ef655e89c4 100644 --- a/ui-opentui/src/view/tools/defaultTool.tsx +++ b/ui-opentui/src/view/tools/defaultTool.tsx @@ -8,9 +8,9 @@ * `(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 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 + * 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). */ @@ -24,11 +24,13 @@ 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 → 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. + * 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)