feat(tui): add /status session diagnostics

This commit is contained in:
ZiyaZhang
2026-07-22 03:10:14 -07:00
parent 988dd70e7c
commit 280207c824
13 changed files with 316 additions and 42 deletions

View File

@@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
@@ -22,6 +22,8 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place.
## Config

View File

@@ -940,6 +940,14 @@ function sessionTokens(session: Session): SessionTokenTotals {
return totals
}
function formatDiagnosticNumber(value: number): string {
return value.toLocaleString('en-US')
}
function formatDiagnosticTime(value: number): string {
return new Date(value).toISOString()
}
class FooterComponent implements Component {
constructor(
private readonly agent: Agent,
@@ -1953,6 +1961,44 @@ export function createTuiChat(
requestRender()
}
const showStatus = (): void => {
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))}%)`
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))
chat.addChild(new Spacer(1))
chat.addChild(card)
requestRender()
}
// Skill listing is async while `createTuiChat` is synchronous, so the
// completions rebuild once the catalog resolves. Disabled-for-model skills
// are absent from `list()`, so they never appear as completions; a user can
@@ -2041,6 +2087,11 @@ export function createTuiChat(
description: 'List this workspace\'s resumable sessions',
handler: () => { showResume(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'status',
description: 'Show detailed session diagnostics',
handler: () => { showStatus(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'exit',
description: 'Exit after the active turn reaches idle',

View File

@@ -24,6 +24,8 @@ interface FakeAgent extends Agent {
export interface TuiHarnessOptions {
status?: AgentStatus
config?: Config
/** Leave the session event log empty instead of seeding one turn and step. */
omitInitialLifecycle?: boolean
/** Omit the harness's default `welcome`, exercising the banner sweep-reveal path. */
omitWelcome?: boolean
tools?: Record<string, ToolDefinition>
@@ -120,11 +122,13 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
sessionId,
options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
)
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn: 1, step: 1 })
if (options.omitInitialLifecycle !== true) {
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn: 1, step: 1 })
}
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []

View File

@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=1 progress=inactive
title "DSH snapshot"
cursor visible column=0 viewportRow=30 bufferRow=30
cursor visible column=0 viewportRow=31 bufferRow=31
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -36,26 +36,28 @@ buffer
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| " /skill:<name> [instructions] — load a skill into the conversation "
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
19| <blank>
20| " provider stream failed after partial output "
20| <blank>
21| " provider stream failed after partial output "
style 1-43 fg=red
21| <blank>
22| " The previous process ended during this turn. "
22| <blank>
23| " The previous process ended during this turn. "
style 1-44 fg=yellow
23| <blank>
24| " Unknown command: /unknown-advanced-command "
24| <blank>
25| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
25| "────────────────────────────────────────────────────────────────────────────────────────────"
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
26| " "
27| " "
style 1-1 inverse
27| "────────────────────────────────────────────────────────────────────────────────────────────"
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
29-31| <blank>
30-31| <blank>

View File

@@ -1,7 +1,7 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=26 bufferRow=26
cursor hidden column=1 viewportRow=27 bufferRow=27
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -36,26 +36,28 @@ buffer
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| " /skill:<name> [instructions] — load a skill into the conversation "
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
19| <blank>
20| " provider stream failed after partial output "
20| <blank>
21| " provider stream failed after partial output "
style 1-43 fg=red
21| <blank>
22| " The previous process ended during this turn. "
22| <blank>
23| " The previous process ended during this turn. "
style 1-44 fg=yellow
23| <blank>
24| " Unknown command: /unknown-advanced-command "
24| <blank>
25| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
25| "────────────────────────────────────────────────────────────────────────────────────────────"
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
26| " "
27| " "
style 1-1 inverse
27| "────────────────────────────────────────────────────────────────────────────────────────────"
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
29-31| <blank>
30-31| <blank>

View File

@@ -0,0 +1,79 @@
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
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| <blank>
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| <blank>
9| " Assistant "
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
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| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| " "
style 1-1 inverse
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
29| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed"
style 0-57 dim
style 64-91 dim
30-31| <blank>

View File

@@ -1,7 +1,7 @@
import { mkdir, readdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
@@ -48,6 +48,7 @@ const CHECKPOINTS = [
'errors-and-help',
'disposed-terminal',
'resume-sessions',
'status-diagnostics',
] as const
type Checkpoint = typeof CHECKPOINTS[number]
@@ -637,6 +638,43 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
it('pins the detailed session diagnostics card', async () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z'))
const harness = await setupSnapshot({
contextWindow: 128_000,
contextTokens: 42_000,
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
beforeMount(session) {
appendUser(session, 'inspect this session')
appendAssistant(session, [{ type: 'text', text: 'Session inspected.' }], {
inputTokens: 1_250,
outputTokens: 340,
cacheReadTokens: 3_000,
cacheWriteTokens: 250,
})
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('status-call'),
name: 'read',
arguments: '{"path":"README.md"}',
})
session.append('session/title', {
title: 'Inspect session diagnostics',
messageSeqs: [1],
source: { kind: 'fallback' },
})
},
}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
harness.terminal.send('/status')
harness.terminal.send('\r')
})
await checkpoint('status-diagnostics', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
dateNow.mockRestore()
})
})
afterAll(async () => {

View File

@@ -845,6 +845,92 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result)
})
it('shows detailed session diagnostics while the agent is running', async () => {
const timestamp = Date.parse('2026-07-22T09:10:11.000Z')
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp)
const result = await setup({
cwd: '/workspace/status',
contextWindow: 128_000,
contextTokens: 42_000,
config: { showReasoning: false },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
beforeMount(session) {
session.append('session/title', {
title: 'Inspect status \u001B]2;unsafe\u0007',
messageSeqs: [1],
source: { kind: 'fallback' },
})
appendAssistant(session, [{ type: 'text', text: 'measured' }], {
inputTokens: 1_250,
outputTokens: 340,
cacheReadTokens: 3_000,
cacheWriteTokens: 250,
})
session.append('tool/call', {
turn: 1, step: 1, callId: 'status-call-1' as never, name: 'read', arguments: '{}',
})
session.append('tool/call', {
turn: 1, step: 1, callId: 'status-call-2' as never, name: 'write', arguments: '{}',
})
},
})
result.agent.status = 'running'
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
result.terminal.send('/status')
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).not.toContain('\u001B]2;unsafe\u0007')
await dispose(result)
dateNow.mockRestore()
})
it('labels unavailable status diagnostics without inventing values', async () => {
const timestamp = Date.parse('2026-07-22T10:11:12.000Z')
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp)
const result = await setup({
cwd: null,
omitInitialLifecycle: true,
contextTokens: 7,
agentOptions: {},
catalog: {
providers: [],
models: [],
resolveModelContext: () => Promise.resolve(undefined),
},
})
result.terminal.send('/status')
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')
await dispose(result)
dateNow.mockRestore()
})
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
const result = await setup()