opentui(v6): tool lifecycle states — live elapsed tick + failed glyph

This commit is contained in:
alt-glitch 2026-06-10 19:12:45 +05:30
parent b6dc49200d
commit 076aebc7e6
4 changed files with 265 additions and 12 deletions

View file

@ -45,6 +45,9 @@ export interface ToolPartState {
args?: Record<string, unknown>
/** Tool wall-clock seconds (gateway `duration_s`), shown dim in the header. */
duration?: number
/** Local Date.now() stamped on `tool.start` drives the live elapsed tick
* while running (Epic 2.5). `duration` (gateway truth) wins once settled. */
startedAt?: number
/** Tidy note when the gateway truncated output (e.g. "5 lines / 234 chars"). */
omittedNote?: string
/** FULL raw unified diff from file-edit tools (gateway `diff_unified`, 512KB-capped). */
@ -224,6 +227,29 @@ function readOptNum(payload: { readonly [k: string]: unknown }, key: string): nu
return typeof v === 'number' ? v : undefined
}
/**
* A failed tool's failure signal, derived from the raw `result`: hermes tools
* return `{"error": "…"}` JSON on failure, and the gateway never sets a
* top-level `error` on tool.complete so this is THE live trigger for the
* failed lifecycle state (Epic 2.5). Flattened to one line for the header
* subtitle; absent/empty/non-dict results yield undefined (not failed).
*/
function resultError(result: unknown): string | undefined {
let v: unknown = result
if (typeof v === 'string') {
try {
v = JSON.parse(v)
} catch {
return undefined
}
}
if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined
const e = (v as Record<string, unknown>)['error']
if (typeof e !== 'string') return undefined
const flat = e.replace(/\s+/g, ' ').trim()
return flat ? flat.slice(0, 400) : undefined
}
/** Render a raw tool `result` for display: strings as-is, anything else pretty
* JSON both then go through the same envelope-strip pipeline as result_text. */
function stringifyResult(v: unknown): string | undefined {
@ -632,7 +658,7 @@ export function createSessionStore() {
setState(
produce(draft => {
const live = ensureAssistant(draft)
const part: ToolPartState = { type: 'tool', id, name, state: 'running' }
const part: ToolPartState = { type: 'tool', id, name, state: 'running', startedAt: Date.now() }
if (argsPreview) part.argsPreview = argsPreview
if (argsText) part.argsText = argsText
;(live.parts ??= []).push(part)
@ -644,7 +670,9 @@ export function createSessionStore() {
const id = readStr(event.payload, 'tool_id')
if (!id) break
const name = readStr(event.payload, 'name')
const error = readStr(event.payload, 'error')
// explicit payload error wins; else derive from the `{"error": …}` result
// convention (the only failure signal the live gateway actually ships).
const error = readStr(event.payload, 'error') ?? resultError(event.payload['result'])
const summary = readStr(event.payload, 'summary')
// `result_text` is verbose-gated, but the raw `result` is ALWAYS sent —
// when the verbose text is absent, derive the display body from `result`

View file

@ -9,7 +9,7 @@
* (found by scanning the frame). The long-output cap is asserted at the Body
* level (a tall frame would otherwise hide the trailing note).
*/
import { describe, expect, test } from 'vitest'
import { describe, expect, test, vi } from 'vitest'
import { createSessionStore, type ToolPartState } from '../logic/store.ts'
import { App } from '../view/App.tsx'
@ -418,6 +418,153 @@ describe('file tool — output suppression under a rendered diff (no raw JSON, e
})
})
describe('tool lifecycle states — running / done / failed (Epic 2.5)', () => {
// Fake ONLY setInterval/clearInterval/Date: the shared elapsed tick + the
// Date.now() it invalidates. setTimeout/microtasks stay REAL — the test
// renderer's settle dance (flush/waitForFrame) depends on them, and the
// render scheduler itself times via performance.now (unfaked).
const FAKED = ['setInterval', 'clearInterval', 'Date'] as const
test('store: tool.start stamps startedAt; tool.complete settles the gateway duration', () => {
vi.useFakeTimers({ toFake: [...FAKED] })
try {
vi.setSystemTime(1_750_000_000_000)
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'l1', name: 'terminal', context: 'sleep 8' } })
const live = store.state.messages[store.state.messages.length - 1]
const part = live?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'l1')
expect(part?.startedAt).toBe(1_750_000_000_000)
expect(part?.state).toBe('running')
// the gateway's duration_s remains the settled truth, startedAt untouched
store.apply({ type: 'tool.complete', payload: { tool_id: 'l1', name: 'terminal', duration_s: 8.2 } })
expect(part?.state).toBe('complete')
expect(part?.duration).toBe(8.2)
expect(part?.startedAt).toBe(1_750_000_000_000)
} finally {
vi.useRealTimers()
}
})
test('store: a `{"error": …}` result derives part.error (the gateway never ships a top-level error)', () => {
const store = createSessionStore()
seedTool(
store,
{ tool_id: 'l2', name: 'read_file' },
{ tool_id: 'l2', name: 'read_file', result: { error: 'File not found:\n /nope.txt' } }
)
const last = store.state.messages[store.state.messages.length - 1]
const part = last?.parts?.find((p): p is ToolPartState => p.type === 'tool' && p.id === 'l2')
expect(part?.error).toBe('File not found: /nope.txt') // flattened to one line
// …and a clean result stays un-failed
seedTool(
store,
{ tool_id: 'l3', name: 'terminal' },
{ tool_id: 'l3', name: 'terminal', result: { exit_code: 0, output: 'ok' } }
)
const part3 = store.state.messages[store.state.messages.length - 1]?.parts?.find(
(p): p is ToolPartState => p.type === 'tool' && p.id === 'l3'
)
expect(part3?.error).toBeUndefined()
})
test('running tool shows ⚡ + a LIVE elapsed that advances with the clock — and no expand glyph', async () => {
vi.useFakeTimers({ toFake: [...FAKED] })
try {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ type: 'tool.start', payload: { tool_id: 'r1', name: 'terminal', context: 'sleep 8' } })
const probe = await mountApp(store)
try {
const f0 = await probe.waitForFrame(f => f.includes('terminal'))
expect(f0).toContain('⚡') // running head glyph
expect(f0).toContain('sleep 8') // subtitle shows while running
expect(f0).toContain('· 0s') // elapsed starts at zero
expect(f0).not.toContain('▶') // NO expand affordance while running
expect(f0).not.toContain('▼')
vi.advanceTimersByTime(3000) // shared tick fires 3× → repaint
const f3 = await probe.waitForFrame(f => f.includes('· 3s'))
expect(f3).toContain('· 3s')
expect(f3).not.toContain('· 0s')
vi.advanceTimersByTime(9000) // …and keeps advancing (12s total)
const f12 = await probe.waitForFrame(f => f.includes('· 12s'))
expect(f12).toContain('· 12s')
expect(f12).toContain('⚡') // still running, still no ▶/▼
expect(f12).not.toContain('▶')
} finally {
probe.destroy()
}
} finally {
vi.useRealTimers()
}
})
test('failed tool reads as failed from the HEAD GLYPH (✗) and stays expandable when there is a body', async () => {
const store = createSessionStore()
seedTool(
store,
{ tool_id: 'e1', name: 'terminal', context: 'false' },
{
tool_id: 'e1',
name: 'terminal',
args: { command: 'false' },
error: 'exit status 1',
result_text: 'boom line one\nboom line two',
duration_s: 0.1
}
)
const probe = await mountApp(store)
try {
const frame = await probe.waitForFrame(f => f.includes('terminal'))
const row = frame.split('\n').find(line => line.includes('terminal')) ?? ''
// ✗ IN the head-glyph position (immediately before the name, after the
// assistant row's `⚕` gutter), replacing the expand glyph…
expect(row).toContain('✗ terminal')
expect(row).not.toContain('▶')
expect(row).toContain('✗ exit status 1') // error subtitle stays
expect(row).toContain('(2 lines)') // body presence still signposted
await clickHeader(probe, 'terminal') // still expandable: body has the output
const expanded = await probe.waitForFrame(f => f.includes('boom line one'))
expect(expanded).toContain('boom line one')
expect(expanded).toContain('boom line two')
} finally {
probe.destroy()
}
})
test('settled success keeps the ▶/▼ + duration contract (glyph never error-colored ✗)', async () => {
const store = createSessionStore()
seedTool(
store,
{ tool_id: 'k1', name: 'terminal', context: 'ls' },
{ tool_id: 'k1', name: 'terminal', args: { command: 'ls' }, result_text: 'a\nb', duration_s: 0.3 }
)
const probe = await mountApp(store)
try {
const frame = await probe.waitForFrame(f => f.includes('terminal'))
const row = frame.split('\n').find(line => line.includes('terminal')) ?? ''
expect(row).toContain('▶ terminal') // head-glyph position, after the ⚕ gutter
expect(row).toContain('· 0.3s')
expect(row).not.toContain('✗')
expect(row).not.toContain('⚡')
await clickHeader(probe, 'terminal')
const expanded = await probe.waitForFrame(f => f.includes('▼'))
expect(expanded).toContain('▼ terminal')
} finally {
probe.destroy()
}
})
})
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

View file

@ -0,0 +1,38 @@
/**
* elapsed.ts the SHARED 1-second tick behind every running tool's live
* elapsed counter (Epic 2.5). ONE module-level signal driven by ONE
* setInterval for the whole app never a timer per part. Refcounted: the
* interval starts when the first subscriber mounts and is cleared when the
* last one cleans up, so an idle transcript schedules zero wakeups.
*
* `useElapsedTick()` must be called inside a reactive scope that exists only
* while ticking is needed (e.g. a component under `<Show when={running()}>`)
* its `onCleanup` is what releases the subscription. Read the returned
* accessor in a TRACKING scope (JSX/memo/effect); the tick value itself is
* meaningless it exists to invalidate `Date.now()`-based computations.
*/
import { createSignal, onCleanup } from 'solid-js'
const [tick, setTick] = createSignal(0)
let subscribers = 0
let timer: ReturnType<typeof setInterval> | undefined
/** Subscribe the current reactive scope to the shared 1s tick (refcounted). */
export function useElapsedTick(): () => number {
subscribers++
timer ??= setInterval(() => setTick(t => t + 1), 1000)
onCleanup(() => {
subscribers--
if (subscribers <= 0 && timer) {
clearInterval(timer)
timer = undefined
}
})
return tick
}
/** Whole seconds since `startedAt` (clamped at 0) — pair with the tick. */
export function elapsedSeconds(startedAt: number): number {
return Math.max(0, Math.floor((Date.now() - startedAt) / 1000))
}

View file

@ -5,13 +5,18 @@
* what's INSIDE varies per tool and is dispatched through the tool renderer
* registry (`view/tools/registry.tsx`, Epic 2.2):
*
* terminal sleep 8 · 12s running (elapsed ticks live)
* terminal ls -la src · 0.3s (12 lines) collapsed (default)
* terminal ls -la src · 0.3s expanded header
* <renderer body> labeled fields / output /
* terminal exit 1 · 0.1s (3 lines) failed (error-colored glyph)
*
* ``/`` marks expandable tools; clicking the header toggles it (wrapped in
* useScrollAnchor so expanding never yanks the viewport). Running tools show
* `name …`. The header row is chrome (selectable=false) a free-form drag
* Lifecycle is legible from the HEAD GLYPH alone (Epic 2.5): `` running (with
* a live `· Ns` elapsed off the shared 1s tick in `elapsed.ts` never a timer
* per part), ``/`` settled-expandable, `` failed (theme error color).
* Clicking an expandable header toggles it (wrapped in useScrollAnchor so
* expanding never yanks the viewport); running parts have no expand
* affordance. The header row is chrome (selectable=false) a free-form drag
* copies only the expanded body content. Fully themed (no hardcoded styles).
*/
import { type ToolPartState } from '../logic/store.ts'
@ -19,6 +24,7 @@ import { useDimensions } from './dimensions.tsx'
import { createSignal, Show } from 'solid-js'
import { truncate } from '../logic/toolOutput.ts'
import { elapsedSeconds, useElapsedTick } from './elapsed.ts'
import { useScrollAnchor } from './scrollAnchor.tsx'
import { useSessionInfo } from './sessionInfo.tsx'
import { useTheme } from './theme.tsx'
@ -35,6 +41,31 @@ function fmtDuration(s: number): string {
return r ? `${m}m ${r}s` : `${m}m`
}
/** Live elapsed format — whole seconds (the tick advances 1s at a time). */
function fmtElapsed(s: number): string {
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const r = s % 60
return r ? `${m}m ${r}s` : `${m}m`
}
/**
* Live ` · 12s` elapsed for a RUNNING part. Mounted only under the running
* `<Show>`, so its useElapsedTick subscription starts/stops the SHARED 1s
* interval with the part's lifecycle (the last cleanup clears it). Falls back
* to the plain `` marker when startedAt is unknown (e.g. a tool.complete
* that arrived without a local tool.start).
*/
function RunningElapsed(props: { startedAt: number | undefined }) {
const theme = useTheme()
const tick = useElapsedTick()
const text = () => {
tick() // re-read every shared tick — Date.now() alone is not reactive
return props.startedAt === undefined ? ' …' : ` · ${fmtElapsed(elapsedSeconds(props.startedAt))}`
}
return <span style={{ fg: theme().color.muted }}>{text()}</span>
}
export function ToolPart(props: { part: ToolPartState }) {
const theme = useTheme()
const dims = useDimensions()
@ -56,10 +87,14 @@ export function ToolPart(props: { part: ToolPartState }) {
// Optional `+N M` change summary (file tools) — themed, settled parts only.
const stats = () => (running() || props.part.error ? undefined : renderer().stats?.(props.part))
const headGlyph = () => (collapsible() ? (expanded() ? '▼' : '▶') : '⚡')
// Failed parts are legible from the glyph alone: `✗` in the head position
// (error-colored), regardless of expandability — `(N lines)` still marks an
// 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).
const headColor = () => (props.part.error ? 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)
return (
@ -80,10 +115,10 @@ export function ToolPart(props: { part: ToolPartState }) {
never the header label. */}
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{props.part.name}</span>
<Show when={running()}>
<span style={{ fg: theme().color.muted }}> </span>
</Show>
<Show when={!running() && subtitle()}>
{/* 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. */}
<Show when={subtitle()}>
<span style={{ fg: props.part.error ? theme().color.error : theme().color.muted }}>
{` ${truncate(subtitle(), subWidth())}`}
</span>
@ -106,6 +141,11 @@ export function ToolPart(props: { part: ToolPartState }) {
<Show when={!running() && props.part.duration !== undefined}>
<span style={{ fg: theme().color.muted }}>{` · ${fmtDuration(props.part.duration ?? 0)}`}</span>
</Show>
{/* live elapsed (running only) the <Show> scopes the shared-tick
subscription to the running lifecycle (see RunningElapsed). */}
<Show when={running()}>
<RunningElapsed startedAt={props.part.startedAt} />
</Show>
<Show when={collapsible() && !expanded() && lines().length > 1}>
<span style={{ fg: theme().color.muted }}>{` (${lines().length} lines)`}</span>
</Show>