diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 78967abcd5..e9991b1404 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -104,7 +104,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // Gating /status on it keeps the assertion race-free; the diagnostics // card is then exercised through the same real Loader/PTY composition. { waitFor: 'scripted session title — DeepSeek Harness', send: '/status\r' }, - { waitFor: 'Session diagnostics', send: '/exit\r' }, + { waitFor: 'Session status', send: '/exit\r' }, ], }) expect(output).toContain('I need one decision before I continue.') @@ -116,12 +116,12 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('Safe') expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') - expect(output).toContain('Session diagnostics') + expect(output).toContain('Session status') expect(output).toContain('Title') expect(output).toContain('scripted session title') expect(output).toContain('Model') expect(output).toContain('tui-scripted/tui-scripted-model-pro') - expect(output).toContain('KV cache hit') + expect(output).toContain('KV cache') expect(output).toContain('Context') expect(output).toContain('128,000') expect(output).toContain('\u001B[?2004l') diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index b54c3c2570..cd6ab99f2b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -945,7 +945,70 @@ function formatDiagnosticNumber(value: number): string { } function formatDiagnosticTime(value: number): string { - return new Date(value).toISOString() + return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC') +} + +function formatDiagnosticCount(value: number, singular: string): string { + return `${String(value)} ${singular}${value === 1 ? '' : 's'}` +} + +function diagnosticMeter(percent: number, palette: Palette): string { + const width = 16 + const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width) + return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}` +} + +type StatusCardRow = readonly [label: string, value: string] + +/** Bordered, grouped field card for one point-in-time status snapshot. */ +class StatusCardComponent implements Component { + constructor( + private readonly groups: readonly (readonly StatusCardRow[])[], + private readonly palette: Palette, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`)) + const naturalLabelWidth = Math.max(...labels.map(label => label.length)) + const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) => + 1 + naturalLabelWidth + 2 + visibleWidth(value)))) + const cardWidth = Math.min( + Math.max(8, width), + Math.max('Session status'.length + 5, naturalBodyWidth + 4), + ) + const innerWidth = Math.max(1, cardWidth - 4) + const labelWidth = Math.min( + naturalLabelWidth, + Math.max(1, Math.floor(innerWidth / 3)), + ) + const body: string[] = [] + for (const [groupIndex, group] of this.groups.entries()) { + if (groupIndex > 0) body.push('') + for (const [label, value] of group) { + const plainLabel = truncateToWidth(`${label}:`, labelWidth, '') + const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} ` + const continuation = ' '.repeat(1 + labelWidth + 2) + const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix)) + const wrapped = wrapTextWithAnsi(value, valueWidth) + for (const [lineIndex, line] of wrapped.entries()) { + body.push(`${lineIndex === 0 ? prefix : continuation}${line}`) + } + } + } + + const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '') + const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5)) + const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}` + const lines = [top] + for (const line of body) { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`) + } + lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`)) + return lines + } } class FooterComponent implements Component { @@ -1965,35 +2028,45 @@ export function createTuiChat( const events = agent.session.events const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) - const context = contextWindow === undefined - ? `${formatDiagnosticNumber(usedContext)} used · capacity unknown` - : `${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)} (${String(Math.round(usedContext / contextWindow * 100))}%)` + let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown` + if (contextWindow !== undefined) { + const contextPercent = Math.round(usedContext / contextWindow * 100) + context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})` + } const rate = cacheHitRate(tokens) - const rows = [ - ['Session', agent.session.id], - ['Title', sessionTitle ?? 'untitled'], - ['Working dir', cwd], - ['Model', target.current === undefined ? 'unset' : targetLabel(target.current)], - ['Reasoning view', showReasoning ? 'shown' : 'hidden'], - ['Agent', agent.status], - ['Activity', [ - `events ${String(events.length)}`, - `turns ${String(events.filter(event => event.type === 'turn/start').length)}`, - `steps ${String(events.filter(event => event.type === 'step/start').length)}`, - `tool calls ${String(events.filter(event => event.type === 'tool/call').length)}`, - ].join(' · ')], - ['Tokens', `input ${formatDiagnosticNumber(tokens.input)} · output ${formatDiagnosticNumber(tokens.output)}`], - ['Cache tokens', `read ${formatDiagnosticNumber(tokens.cacheRead)} · write ${formatDiagnosticNumber(tokens.cacheWrite)}`], - ['KV cache hit', rate === undefined ? 'n/a' : `${String(rate)}%`], - ['Context', context], - ['Created', formatDiagnosticTime(agent.session.header.createdAt)], - ['Last active', formatDiagnosticTime(latestActivity)], - ] as const - const labelWidth = Math.max(...rows.map(([label]) => label.length)) - const card = new GutterBox(text => palette.accent(text), 0) - card.addChild(new Text(palette.bold(palette.accent('Session diagnostics')), 0, 0)) - card.addChild(new Text(rows.map(([label, value]) => - `${palette.muted(label.padEnd(labelWidth))} ${displayText(value)}`).join('\n'), 0, 0)) + const turns = events.filter(event => event.type === 'turn/start').length + const steps = events.filter(event => event.type === 'step/start').length + const toolCalls = events.filter(event => event.type === 'tool/call').length + const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current)) + const groups: readonly (readonly StatusCardRow[])[] = [ + [ + ['Session', displayText(agent.session.id)], + ['Title', displayText(sessionTitle ?? 'untitled')], + ['Directory', displayText(cwd)], + ['Model', `${model} ${palette.dim(`(reasoning ${showReasoning ? 'shown' : 'hidden'})`)}`], + ], + [ + ['Agent', [ + agent.status, + formatDiagnosticCount(events.length, 'event'), + formatDiagnosticCount(turns, 'turn'), + formatDiagnosticCount(steps, 'step'), + formatDiagnosticCount(toolCalls, 'tool call'), + ].join(' · ')], + ], + [ + ['Tokens', `${formatDiagnosticNumber(tokens.input)} input + ${formatDiagnosticNumber(tokens.output)} output`], + ['KV cache', rate === undefined + ? `n/a (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)` + : `${diagnosticMeter(rate, palette)} ${String(rate)}% hit (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)`], + ['Context', context], + ], + [ + ['Created', formatDiagnosticTime(agent.session.header.createdAt)], + ['Active', formatDiagnosticTime(latestActivity)], + ], + ] + const card = new StatusCardComponent(groups, palette) chat.addChild(new Spacer(1)) chat.addChild(card) requestRender() diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt new file mode 100644 index 0000000000..de46d59543 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt @@ -0,0 +1,110 @@ +terminal 56x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "Inspect session diagnostics — DSH snapshot" +cursor hidden column=1 viewportRow=32 bufferRow=32 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Inspect session diagnostics" + style 1-27 fg=bright-black +2| " deepseek-v4-pro • main-session" + style 1-32 dim +3| +4| "▌ " + style 0-0 fg=bright-blue +5| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +6| "▌ inspect this session " + style 0-0 fg=bright-blue +7| "▌ " + style 0-0 fg=bright-blue +8| +9| " Assistant " + style 1-9 fg=bright-magenta bold +10| " Session inspected. " +11| +12| "╭─ Session status ─────────────────────────────────────╮" + style 0-2 dim + style 3-16 fg=bright-blue bold + style 17-55 dim +13| "│ Session: main-session │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +14| "│ Title: Inspect session diagnostics │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +15| "│ Directory: /workspace/project │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +16| "│ Model: deepseek/deepseek-v4-pro (reasoning │" + style 0-0 dim + style 3-12 fg=bright-black + style 40-55 dim +17| "│ shown) │" + style 0-0 dim + style 15-20 dim + style 55-55 dim +18| "│ │" + style 0-0 dim + style 55-55 dim +19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +20| "│ tool call │" + style 0-0 dim + style 55-55 dim +21| "│ │" + style 0-0 dim + style 55-55 dim +22| "│ Tokens: 1,250 input + 340 output │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │" + style 0-0 dim + style 3-12 fg=bright-black + style 15-15 dim + style 16-26 fg=bright-blue + style 27-32 dim + style 55-55 dim +24| "│ + 250 write) │" + style 0-0 dim + style 55-55 dim +25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │" + style 0-0 dim + style 3-12 fg=bright-black + style 15-15 dim + style 16-20 fg=bright-blue + style 21-32 dim + style 55-55 dim +26| "│ 128,000) │" + style 0-0 dim + style 55-55 dim +27| "│ │" + style 0-0 dim + style 55-55 dim +28| "│ Created: 2026-07-22 09:10:11 UTC │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +29| "│ Active: 2026-07-22 09:10:11 UTC │" + style 0-0 dim + style 3-12 fg=bright-black + style 55-55 dim +30| "╰──────────────────────────────────────────────────────╯" + style 0-55 dim +31| "────────────────────────────────────────────────────────" + style 0-55 dim +32| " " + style 1-1 inverse +33| "────────────────────────────────────────────────────────" + style 0-55 dim +34| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 6" + style 0-55 dim +35| diff --git a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt index 67fe587e4c..73d98953f6 100644 --- a/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt +++ b/packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Inspect session diagnostics — DSH snapshot" -cursor hidden column=1 viewportRow=27 bufferRow=27 +cursor hidden column=1 viewportRow=28 bufferRow=28 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -25,55 +25,75 @@ buffer style 1-9 fg=bright-magenta bold 10| " Session inspected. " 11| -12| "▌ Session diagnostics " - style 0-0 fg=bright-blue - style 2-20 fg=bright-blue bold -13| "▌ Session main-session " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -14| "▌ Title Inspect session diagnostics " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -15| "▌ Working dir /workspace/project " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -16| "▌ Model deepseek/deepseek-v4-pro " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -17| "▌ Reasoning view shown " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -18| "▌ Agent idle " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -19| "▌ Activity events 6 · turns 1 · steps 1 · tool calls 1 " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -20| "▌ Tokens input 1,250 · output 340 " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -21| "▌ Cache tokens read 3,000 · write 250 " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -22| "▌ KV cache hit 67% " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -23| "▌ Context 42,000 / 128,000 (33%) " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -24| "▌ Created 2026-07-22T09:10:11.000Z " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -25| "▌ Last active 2026-07-22T09:10:11.000Z " - style 0-0 fg=bright-blue - style 2-15 fg=bright-black -26| "────────────────────────────────────────────────────────────────────────────────────────────" +12| "╭─ Session status ─────────────────────────────────────────────────╮" + style 0-2 dim + style 3-16 fg=bright-blue bold + style 17-67 dim +13| "│ Session: main-session │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +14| "│ Title: Inspect session diagnostics │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +15| "│ Directory: /workspace/project │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +16| "│ Model: deepseek/deepseek-v4-pro (reasoning shown) │" + style 0-0 dim + style 3-12 fg=bright-black + style 40-56 dim + style 67-67 dim +17| "│ │" + style 0-0 dim + style 67-67 dim +18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +19| "│ │" + style 0-0 dim + style 67-67 dim +20| "│ Tokens: 1,250 input + 340 output │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │" + style 0-0 dim + style 3-12 fg=bright-black + style 15-15 dim + style 16-26 fg=bright-blue + style 27-32 dim + style 67-67 dim +22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │" + style 0-0 dim + style 3-12 fg=bright-black + style 15-15 dim + style 16-20 fg=bright-blue + style 21-32 dim + style 67-67 dim +23| "│ │" + style 0-0 dim + style 67-67 dim +24| "│ Created: 2026-07-22 09:10:11 UTC │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +25| "│ Active: 2026-07-22 09:10:11 UTC │" + style 0-0 dim + style 3-12 fg=bright-black + style 67-67 dim +26| "╰──────────────────────────────────────────────────────────────────╯" + style 0-67 dim +27| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -27| " " +28| " " style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────" +29| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -29| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed" +30| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed" style 0-57 dim style 64-91 dim -30-31| +31| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index e8a0897805..97617508ec 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -49,6 +49,7 @@ const CHECKPOINTS = [ 'disposed-terminal', 'resume-sessions', 'status-diagnostics', + 'status-diagnostics-narrow', ] as const type Checkpoint = typeof CHECKPOINTS[number] @@ -672,6 +673,8 @@ describe('TUI terminal-state snapshots', () => { harness.terminal.send('\r') }) await checkpoint('status-diagnostics', harness.terminal, { includeScrollback: true }) + await renderAfter(harness, () => { harness.terminal.resize(56, 36) }) + await checkpoint('status-diagnostics-narrow', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) dateNow.mockRestore() }) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 02f7a0de64..d48289f46d 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -880,22 +880,23 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') await tick() - expect(result.terminal.output).toContain('Session diagnostics') - expect(result.terminal.output).toContain('Session main-session') - expect(result.terminal.output).toContain('Title Inspect status \\x1b]2;unsafe\\x07') - expect(result.terminal.output).toContain('Working dir /workspace/status') - expect(result.terminal.output).toContain('Model deepseek/deepseek-v4-pro') - expect(result.terminal.output).toContain('Reasoning view hidden') - expect(result.terminal.output).toContain('Agent running') - expect(result.terminal.output).toContain('Activity events 6 · turns 1 · steps 1 · tool calls 2') - expect(result.terminal.output).toContain('Tokens input 1,250 · output 340') - expect(result.terminal.output).toContain('Cache tokens read 3,000 · write 250') - expect(result.terminal.output).toContain('KV cache hit 67%') - expect(result.terminal.output).toContain('Context 42,000 / 128,000 (33%)') - expect(result.terminal.output).toContain('Created 2026-07-22T09:10:11.000Z') - expect(result.terminal.output).toContain('Last active 2026-07-22T09:10:11.000Z') + expect(result.terminal.output).toContain('Session status') + expect(result.terminal.output).toContain('main-session') + expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07') + expect(result.terminal.output).toContain('/workspace/status') + expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (reasoning hidden)') + expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls') + expect(result.terminal.output).toContain('1,250 input + 340 output') + expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)') + expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)') + expect(result.terminal.output).toContain('2026-07-22 09:10:11 UTC') expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007') + result.terminal.resize(56) + result.terminal.send('/redraw') + result.terminal.send('\r') + await tick() + await dispose(result) dateNow.mockRestore() }) @@ -918,15 +919,12 @@ describe('pi-tui chat lifecycle and transcript', () => { result.terminal.send('\r') await tick() - expect(result.terminal.output).toContain('Title untitled') - expect(result.terminal.output).toContain('Model unset') - expect(result.terminal.output).toContain('Reasoning view shown') - expect(result.terminal.output).toContain('Agent idle') - expect(result.terminal.output).toContain('Activity events 0 · turns 0 · steps 0 · tool calls 0') - expect(result.terminal.output).toContain('KV cache hit n/a') - expect(result.terminal.output).toContain('Context 7 used · capacity unknown') - expect(result.terminal.output).toContain('Created 2026-07-22T10:11:12.000Z') - expect(result.terminal.output).toContain('Last active 2026-07-22T10:11:12.000Z') + expect(result.terminal.output).toContain('untitled') + expect(result.terminal.output).toContain('unset (reasoning shown)') + expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls') + expect(result.terminal.output).toContain('n/a (0 read + 0 write)') + expect(result.terminal.output).toContain('7 used · capacity unknown') + expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC') await dispose(result) dateNow.mockRestore() })