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)