diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index edb6bda3d1f..cd430469949 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -45,6 +45,9 @@ export interface ToolPartState { args?: Record /** 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)['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` diff --git a/ui-opentui/src/test/tools.test.tsx b/ui-opentui/src/test/tools.test.tsx index 0aad665448b..c2b44e39884 100644 --- a/ui-opentui/src/test/tools.test.tsx +++ b/ui-opentui/src/test/tools.test.tsx @@ -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 diff --git a/ui-opentui/src/view/elapsed.ts b/ui-opentui/src/view/elapsed.ts new file mode 100644 index 00000000000..542af628777 --- /dev/null +++ b/ui-opentui/src/view/elapsed.ts @@ -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 ``) + * — 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 | 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)) +} diff --git a/ui-opentui/src/view/toolPart.tsx b/ui-opentui/src/view/toolPart.tsx index e29c7c326c6..0499a3a299b 100644 --- a/ui-opentui/src/view/toolPart.tsx +++ b/ui-opentui/src/view/toolPart.tsx @@ -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 * │ ← 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 + * ``, 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 {text()} +} + 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. */} {props.part.name} - - - - + {/* 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. */} + {` ${truncate(subtitle(), subWidth())}`} @@ -106,6 +141,11 @@ export function ToolPart(props: { part: ToolPartState }) { {` · ${fmtDuration(props.part.duration ?? 0)}`} + {/* live elapsed (running only) — the scopes the shared-tick + subscription to the running lifecycle (see RunningElapsed). */} + + + 1}> {` (${lines().length} lines)`}