opentui(v6): tool output uncapped by default (env restores a cap)

User feedback: "for all tools, i'd want all their output viewing enabled
to be infinite by default."

Flip envOutputLines (HERMES_TUI_TOOL_OUTPUT_LINES): unset -> Infinity
(was 200); a positive integer RESTORES a cap (e.g. =200); 0 stays
Infinity for back-compat with the old opt-in-unlimited value; garbage ->
Infinity (unrecognized = no cap asked for). The semantic is now "cap
only when the user asked for one".

The store's raw-result preference follows the same rule: envOutputLinesSet
becomes envOutputUnlimited — whenever the cap is unlimited (the default
now) and a gateway tail-capped result_text (omittedNote) arrives with the
always-full raw result on the wire, the raw result wins, since an
uncapped view of a tail would silently miss the head. With an explicit
finite cap the gateway tail + honest omitted note are kept.

Memory safety is unchanged: tool bodies mount only while EXPANDED (rows
default collapsed and free their Yoga nodes on collapse/unmount), and the
rolling HERMES_TUI_MAX_MESSAGES cap bounds the transcript's high-water
mark.

Tests: env.test.ts expectations flipped (unset/garbage -> Infinity, 0
documented as back-compat); tools.test.tsx "flag unset caps at 200"
becomes "unset renders all 250 lines", plus an explicit =50 cap (+note)
test and =200 restored-cap test; the store preference matrix covers
unset/0 (raw wins), =50 (tail+note kept), and no-raw (tail+note, no
crash). Verified live: seq 1 220 expanded renders rows 201-220 with no
"+N more lines" note.
This commit is contained in:
alt-glitch 2026-06-10 22:38:49 +05:30
parent 0bb58b65ec
commit 38eb9bb19a
6 changed files with 116 additions and 63 deletions

View file

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

View file

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

View file

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

View file

@ -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(
() => (
<ThemeProvider>
<BashToolBody part={part} width={70} />
</ThemeProvider>
),
{ 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<string, unknown> = { ...payload }

View file

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

View file

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