opentui(v6): tool-name emphasis, thought styling, HERMES_TUI_TOOL_OUTPUT_LINES
This commit is contained in:
@@ -14,3 +14,29 @@ export function envFlag(value: string | undefined, fallback: boolean): boolean {
|
|||||||
if (FALSE_RE.test(v)) return false
|
if (FALSE_RE.test(v)) return false
|
||||||
return fallback
|
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() ?? '') !== ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type SessionInfoPatchDecoded
|
type SessionInfoPatchDecoded
|
||||||
} from '../boundary/schema/SessionInfo.ts'
|
} from '../boundary/schema/SessionInfo.ts'
|
||||||
import { diffStats, type DiffStats } from './diff.ts'
|
import { diffStats, type DiffStats } from './diff.ts'
|
||||||
|
import { envOutputLinesSet } from './env.ts'
|
||||||
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
|
||||||
import { DEFAULT_THEME, type Theme, themeFromSkin } from './theme.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
|
// (so e.g. bash output still renders in non-verbose sessions). Then peel
|
||||||
// the gateway's "[showing verbose tail; omitted …]" label (item 2) before
|
// the gateway's "[showing verbose tail; omitted …]" label (item 2) before
|
||||||
// envelope-stripping, so the body is clean and the note renders tidily.
|
// 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 ?? ''
|
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 resultText = stripToolEnvelope(rawBody)
|
||||||
const lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0
|
const lineCount = resultText ? resultText.replace(/\s+$/, '').split('\n').length : 0
|
||||||
// `args` (full dict) is always sent; stringify as the expanded-view args
|
// `args` (full dict) is always sent; stringify as the expanded-view args
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'vitest'
|
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', () => {
|
describe('envFlag', () => {
|
||||||
test('recognizes truthy values regardless of case/whitespace', () => {
|
test('recognizes truthy values regardless of case/whitespace', () => {
|
||||||
@@ -29,3 +29,39 @@ describe('envFlag', () => {
|
|||||||
expect(envFlag('enabled', false)).toBe(false)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -12,8 +12,11 @@
|
|||||||
import { describe, expect, test, vi } from 'vitest'
|
import { describe, expect, test, vi } from 'vitest'
|
||||||
|
|
||||||
import { createSessionStore, type ToolPartState } from '../logic/store.ts'
|
import { createSessionStore, type ToolPartState } from '../logic/store.ts'
|
||||||
|
import { DEFAULT_THEME } from '../logic/theme.ts'
|
||||||
import { App } from '../view/App.tsx'
|
import { App } from '../view/App.tsx'
|
||||||
|
import { reasoningLabelStyle } from '../view/reasoningPart.tsx'
|
||||||
import { ThemeProvider } from '../view/theme.tsx'
|
import { ThemeProvider } from '../view/theme.tsx'
|
||||||
|
import { toolNameStyle } from '../view/toolPart.tsx'
|
||||||
import { BashToolBody, commandOf } from '../view/tools/bashTool.tsx'
|
import { BashToolBody, commandOf } from '../view/tools/bashTool.tsx'
|
||||||
import { diffOutputPlan, FileToolBody } from '../view/tools/fileTool.tsx'
|
import { diffOutputPlan, FileToolBody } from '../view/tools/fileTool.tsx'
|
||||||
import { renderProbe, type RenderProbe } from './lib/render.ts'
|
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)', () => {
|
describe('redaction precedence — gateway args_text wins over raw args (security)', () => {
|
||||||
// The gateway redacts verbose `args_text` (server.py _tool_args_text) but
|
// The gateway redacts verbose `args_text` (server.py _tool_args_text) but
|
||||||
// sends the raw `args` dict on tool.complete UNREDACTED. structuredArgs must
|
// sends the raw `args` dict on tool.complete UNREDACTED. structuredArgs must
|
||||||
|
|||||||
@@ -13,12 +13,25 @@
|
|||||||
*/
|
*/
|
||||||
import { createMemo, createSignal, Show } from 'solid-js'
|
import { createMemo, createSignal, Show } from 'solid-js'
|
||||||
|
|
||||||
|
import type { ThemeColors } from '../logic/theme.ts'
|
||||||
import { Markdown } from './markdown.tsx'
|
import { Markdown } from './markdown.tsx'
|
||||||
import { useScrollAnchor } from './scrollAnchor.tsx'
|
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||||
import { useTheme } from './theme.tsx'
|
import { useTheme } from './theme.tsx'
|
||||||
|
|
||||||
const GUTTER = 2
|
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). */
|
/** Split a leading `**Title**\n\n body` into {title, body} (opencode reasoningSummary). */
|
||||||
function reasoningSummary(text: string): { title?: string; body: string } {
|
function reasoningSummary(text: string): { title?: string; body: string } {
|
||||||
const s = (text ?? '').replace('[REDACTED]', '').trim()
|
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
|
— chrome, not the reasoning body — so a free-form drag yields only
|
||||||
the markdown body below, not the section label (item 4). */}
|
the markdown body below, not the section label (item 4). */}
|
||||||
<text selectable={false}>
|
<text selectable={false}>
|
||||||
{/* accent chevron marks it; muted label keeps reasoning in the dim,
|
{/* accent chevron marks it; muted ITALIC label keeps reasoning the
|
||||||
secondary tier alongside tool calls (Ink hierarchy). */}
|
most secondary tier AND visibly a different kind of row than a
|
||||||
<span style={{ fg: theme().color.muted }}>{label()}</span>
|
tool (bold name) — see reasoningLabelStyle. */}
|
||||||
|
<span style={reasoningLabelStyle(theme().color)}>{label()}</span>
|
||||||
<Show when={summary().title}>
|
<Show when={summary().title}>
|
||||||
<span style={{ fg: theme().color.muted }}>{`: ${summary().title}`}</span>
|
<span style={reasoningLabelStyle(theme().color)}>{`: ${summary().title}`}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
* copies only the expanded body content. Fully themed (no hardcoded styles).
|
* copies only the expanded body content. Fully themed (no hardcoded styles).
|
||||||
*/
|
*/
|
||||||
import { type ToolPartState } from '../logic/store.ts'
|
import { type ToolPartState } from '../logic/store.ts'
|
||||||
|
import type { ThemeColors } from '../logic/theme.ts'
|
||||||
import { useDimensions } from './dimensions.tsx'
|
import { useDimensions } from './dimensions.tsx'
|
||||||
import { createSignal, Show } from 'solid-js'
|
import { createSignal, Show } from 'solid-js'
|
||||||
|
|
||||||
@@ -49,6 +50,24 @@ function fmtElapsed(s: number): string {
|
|||||||
return r ? `${m}m ${r}s` : `${m}m`
|
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
|
* Live ` · 12s` elapsed for a RUNNING part. Mounted only under the running
|
||||||
* `<Show>`, so its useElapsedTick subscription starts/stops the SHARED 1s
|
* `<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 ⚡.
|
// expandable body. `error` only lands on tool.complete, so running stays ⚡.
|
||||||
const failed = () => !running() && Boolean(props.part.error)
|
const failed = () => !running() && Boolean(props.part.error)
|
||||||
const headGlyph = () => (failed() ? '✗' : collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
const headGlyph = () => (failed() ? '✗' : collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
|
||||||
// accent glyph MARKS the tool (draws the eye); the rest is muted so tools read
|
// accent glyph MARKS the tool (draws the eye); the NAME is primary (bold text
|
||||||
// as the dim, secondary tier below the bright assistant answer (Ink hierarchy).
|
// 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 headColor = () => (failed() ? theme().color.error : theme().color.accent)
|
||||||
const subWidth = () => Math.max(1, bodyWidth() - props.part.name.length - 2)
|
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,
|
free-form drag over a tool yields only the expanded body content,
|
||||||
never the header label. */}
|
never the header label. */}
|
||||||
<text selectable={false}>
|
<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.
|
{/* subtitle shows while running too (the gateway argsPreview — e.g.
|
||||||
the command being executed) so a running tool reads `⚡ terminal
|
the command being executed) so a running tool reads `⚡ terminal
|
||||||
sleep 8 · 12s`, Ink parity. */}
|
sleep 8 · 12s`, Ink parity. */}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* `process` (Epic 2.4). Collapsed: the COMMAND BEING INVOKED, verbatim, on one
|
* `process` (Epic 2.4). Collapsed: the COMMAND BEING INVOKED, verbatim, on one
|
||||||
* line (the shell truncates to width; expanding reveals the rest). Expanded:
|
* line (the shell truncates to width; expanding reveals the rest). Expanded:
|
||||||
* the full command (`$ `-prefixed, multi-line safe) and the FULL output, kept
|
* 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).
|
* `logic/toolOutput.ts` (via the shared ToolOutputBlock).
|
||||||
*
|
*
|
||||||
* Arg keys verified against the Python tool schemas:
|
* Arg keys verified against the Python tool schemas:
|
||||||
|
|||||||
@@ -8,19 +8,31 @@
|
|||||||
* `(N fields)` / `(N items)` (opencode's primitive-only `input()` idea)
|
* `(N fields)` / `(N items)` (opencode's primitive-only `input()` idea)
|
||||||
* A single field whose value already equals the header's primary-arg preview is
|
* 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
|
* 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 /
|
* envelope-stripped text, capped to the expanded-lines cap (the
|
||||||
* "+N more lines" notes. `ToolOutputBlock` is shared with the per-tool
|
* HERMES_TUI_TOOL_OUTPUT_LINES env var; default 200, 0 → unlimited) with the
|
||||||
* renderers (bash, …). Fully themed; labels/notes are chrome (selectable=false).
|
* 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 { createMemo, For, Show } from 'solid-js'
|
||||||
|
|
||||||
|
import { envOutputLines } from '../../logic/env.ts'
|
||||||
import type { ToolPartState } from '../../logic/store.ts'
|
import type { ToolPartState } from '../../logic/store.ts'
|
||||||
import { collapseToolOutput, truncate } from '../../logic/toolOutput.ts'
|
import { collapseToolOutput, truncate } from '../../logic/toolOutput.ts'
|
||||||
import { useTheme } from '../theme.tsx'
|
import { useTheme } from '../theme.tsx'
|
||||||
import type { ToolBodyProps, ToolRenderer } from './registry.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. */
|
/** Max labeled arg fields shown when expanded. */
|
||||||
const FIELDS_MAX = 16
|
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
|
* 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
|
* per-tool renderers. Caps to expandedMaxLines() and renders the honest
|
||||||
* notes (`omittedNote` from the gateway cap; "+N more lines" from ours).
|
* truncation notes (`omittedNote` from the gateway cap; "+N more lines" from ours).
|
||||||
*/
|
*/
|
||||||
export function ToolOutputBlock(props: { part: ToolPartState; width: number; label?: boolean }) {
|
export function ToolOutputBlock(props: { part: ToolPartState; width: number; label?: boolean }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const result = () => (props.part.resultText ?? '').replace(/\s+$/, '')
|
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 (
|
return (
|
||||||
<Show when={result()}>
|
<Show when={result()}>
|
||||||
{/* section label — chrome, not content */}
|
{/* section label — chrome, not content */}
|
||||||
|
|||||||
Reference in New Issue
Block a user