Merge remote-tracking branch 'origin/master' into worktree/session-reference

# Conflicts:
#	.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	packages/compact/compact-basic/src/region.ts
#	packages/compact/compact/README.md
#	packages/compact/compact/tests/compact.spec.ts
#	packages/examples/acp-demo/package.json
#	packages/ui/tui/README.md
#	packages/ui/tui/package.json
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
Yichen Jiang
2026-07-21 21:10:42 +08:00
629 changed files with 21135 additions and 3315 deletions

View File

@@ -1,9 +1,15 @@
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent, type AgentStatus, type SendOptions } from '@deepseek-ai/dsh-agent'
import AgentRegistry, {
type Agent,
type AgentOptions,
type AgentStatus,
type SendOptions,
} from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createTuiChat, type Config } from '../src/index.ts'
@@ -24,6 +30,16 @@ export interface TuiHarnessOptions {
configureContext?: (ctx: Context) => Promise<void>
beforeMount?: (session: Session) => void
cwd?: string | null
agentOptions?: AgentOptions
contextWindow?: number
contextTokens?: number
now?: () => number
catalog?: {
providers: LlmProviderInfo[]
models: LlmModelInfo[]
listModels?: (provider: string) => Promise<LlmModelInfo[]>
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
}
}
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
@@ -52,6 +68,31 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
const catalog = options.catalog ?? {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' },
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
}
ctx.provide('llm', {
listProviders() {
return catalog.providers.map(provider => ({ ...provider }))
},
listModels(provider: string) {
return catalog.listModels?.(provider)
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
},
resolveModelContext(provider: string, model: string) {
return catalog.resolveModelContext?.(provider, model)
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
},
} as never)
ctx.provide('tokenMeter', {
measure() {
return { totalTokens: options.contextTokens ?? 0 }
},
} as never)
if (options.configureContext === undefined) {
const tools = options.tools ?? {}
ctx.provide('tools', {
@@ -62,11 +103,17 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
} else {
await options.configureContext(ctx)
}
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
const sessionId = SessionId('main-session')
const session = ctx.sessions.create(
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 })
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
@@ -75,7 +122,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const cancelled: string[] = []
const agent: FakeAgent = {
id: sessionId,
options: { model: 'deepseek-v4-flash' },
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
session,
status: options.status ?? 'idle',
ctx,
@@ -105,7 +152,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
welcome: 'Coding agent ready.',
sessionId,
color: false,
}, options.config), { terminal, exit })
}, options.config), { terminal, exit, now: options.now ?? (() => 0) })
return { ctx, session, agent, terminal, exit, controller }
}
@@ -130,11 +177,10 @@ export function appendAssistant(
session: Session,
content: ContentBlock[],
usage?: { inputTokens: number; outputTokens: number },
position: { turn: number; step: number } = { turn: 1, step: 0 },
position: { turn: number; step: number } = { turn: 1, step: 1 },
): void {
session.append('assistant/message', {
turn: position.turn,
step: position.step,
...position,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content,
...usage === undefined ? {} : { usage },

View File

@@ -12,7 +12,15 @@ describe('dsh-tui plugin export shape', () => {
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
expect(unwrapped).toBe(tui)
expect(unwrapped.name).toBe('ui-tui')
expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools'])
expect(unwrapped.inject).toEqual([
'agents',
'commands',
'userInteraction',
'tools',
'llm',
'systemPrompt',
'tokenMeter',
])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})

View File

@@ -33,11 +33,12 @@ buffer
9| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
10| "▌ packages/ui/tui 100% "
10| "▌ … +4 lines (Ctrl+O to expand) "
style 0-0 fg=green
11| "▌ … 4 more lines (Ctrl+O to expand) "
style 2-30 dim
11| "▌ [exit 0] "
style 0-0 fg=green
style 2-34 dim
style 2-9 dim
12| "▌ "
style 0-0 fg=green
13| <blank>
@@ -53,12 +54,12 @@ buffer
17| "▌ - old line "
style 0-0 fg=green
style 2-11 fg=red
18| "▌ - keep "
18| "▌ … +5 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-7 fg=red
19| "▌ … 5 more lines (Ctrl+O to expand) "
style 2-30 dim
19| "▌ + expect(screen).toMatchSnapshot() "
style 0-0 fg=green
style 2-34 dim
style 2-35 fg=green
20| "▌ "
style 0-0 fg=green
21| <blank>
@@ -102,6 +103,6 @@ buffer
style 1-1 inverse
39| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
40| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 67-99 dim
style 42-99 dim

View File

@@ -122,6 +122,6 @@ buffer
style 1-1 inverse
48| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded"
49| "/workspace/project ↑0 ↓0 0% context tools:expanded deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 66-99 dim
style 41-99 dim

View File

@@ -46,7 +46,7 @@ buffer
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
18-35| <blank>

View File

@@ -1,5 +1,5 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
lifecycle started=1 stopped=0 progress=active
title "DSH snapshot"
cursor hidden column=1 viewportRow=17 bufferRow=17
viewport
@@ -41,12 +41,12 @@ viewport
15| " Streaming visible state… "
style 11-23 bold
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
style 0-95 fg=bright-blue
17| " "
style 1-1 inverse
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 63-95 dim
style 0-95 fg=bright-blue
19| "◒ Working · 0s esc interrupt"
style 0-13 fg=bright-blue
style 83-95 dim
20-35| <blank>

View File

@@ -53,7 +53,7 @@ buffer
style 1-1 inverse
19| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
21-35| <blank>

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=29 bufferRow=29
cursor visible column=0 viewportRow=30 bufferRow=30
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -25,7 +25,7 @@ buffer
style 1-18 fg=bright-blue bold
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning "
8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
@@ -38,28 +38,30 @@ buffer
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /reasoning — Toggle reasoning blocks "
15| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
16| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
16| " /redraw — Invalidate components and redraw the terminal "
17| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| <blank>
19| " provider stream failed after partial output "
19| <blank>
20| " provider stream failed after partial output "
style 1-43 fg=red
20| <blank>
21| " The previous process ended during this turn. "
21| <blank>
22| " The previous process ended during this turn. "
style 1-44 fg=yellow
22| <blank>
23| " Unknown command: /unknown-advanced-command "
23| <blank>
24| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
24| "────────────────────────────────────────────────────────────────────────────────────────────"
25| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
25| " "
26| " "
style 1-1 inverse
26| "────────────────────────────────────────────────────────────────────────────────────────────"
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 59-91 dim
28-31| <blank>
style 34-91 dim
29-31| <blank>

View File

@@ -33,8 +33,9 @@ buffer
style 0-0 fg=yellow
10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
style 0-0 fg=yellow
11| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), "
11| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=yellow
style 2-30 dim
12| "▌ ]) "
style 0-0 fg=yellow
13| "▌ phase('Verify') "
@@ -49,7 +50,7 @@ buffer
style 1-1 inverse
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
19| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
20-35| <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=25 bufferRow=25
cursor hidden column=1 viewportRow=26 bufferRow=26
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -25,7 +25,7 @@ buffer
style 1-18 fg=bright-blue bold
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning "
8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
@@ -38,28 +38,30 @@ buffer
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /reasoning — Toggle reasoning blocks "
15| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
16| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
16| " /redraw — Invalidate components and redraw the terminal "
17| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
17| " /tools — Expand or collapse all tool cards "
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
18| <blank>
19| " provider stream failed after partial output "
19| <blank>
20| " provider stream failed after partial output "
style 1-43 fg=red
20| <blank>
21| " The previous process ended during this turn. "
21| <blank>
22| " The previous process ended during this turn. "
style 1-44 fg=yellow
22| <blank>
23| " Unknown command: /unknown-advanced-command "
23| <blank>
24| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
24| "────────────────────────────────────────────────────────────────────────────────────────────"
25| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
25| " "
26| " "
style 1-1 inverse
26| "────────────────────────────────────────────────────────────────────────────────────────────"
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 59-91 dim
28-31| <blank>
style 34-91 dim
29-31| <blank>

View File

@@ -0,0 +1,52 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=31 bufferRow=31
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 91-91 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 91-91 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 91-91 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-91 fg=bright-blue
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| " "
style 1-1 inverse
7| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
8| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 34-91 dim
9-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────╮ "
style 10-81 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "
style 10-10 fg=bright-blue
style 12-72 fg=bright-blue inverse
style 81-81 fg=bright-blue
15| " │ deepseek/deepseek-v4-pro DeepSeek V4 Pro │ "
style 10-10 fg=bright-blue
style 38-60 fg=bright-black
style 81-81 fg=bright-blue
16| " │ │ "
style 10-10 fg=bright-blue
style 81-81 fg=bright-blue
17| " │ ↑/↓ navigate • Enter select • Esc cancel │ "
style 10-10 fg=bright-blue
style 12-51 dim
style 81-81 fg=bright-blue
18| " ╰──────────────────────────────────────────────────────────────────────╯ "
style 10-81 fg=bright-blue
19-31| <blank>

View File

@@ -0,0 +1,35 @@
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=8 bufferRow=8
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 91-91 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 91-91 fg=bright-blue
3| "│ deepseek-v4-pro • main-session │"
style 0-0 fg=bright-blue
style 2-33 dim
style 91-91 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-91 fg=bright-blue
5| <blank>
6| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 1-64 fg=bright-black
7| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
8| " "
style 1-1 inverse
9| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
10| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-pro(reasoning:on)"
style 0-24 dim
style 36-91 dim
11-31| <blank>

View File

@@ -1,7 +1,7 @@
terminal 56x20 buffer=normal length=20 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=56 viewportRow=13 bufferRow=13
cursor hidden column=56 viewportRow=17 bufferRow=17
viewport
0| "╭──────────────────────────────────────────────────────╮"
style 0-55 fg=bright-blue
@@ -18,52 +18,30 @@ viewport
style 0-0 fg=bright-blue
style 2-35 dim
style 55-55 fg=bright-blue
4| "╰───╭ Coverage ───────────────────────────────────────╯"
4| "╰──────────────────────────────────────────────────────╯"
style 0-55 fg=bright-blue
5| "────│ Which advanced TUI states belong in the │────"
style 0-3 dim
style 4-4 fg=bright-blue
style 6-50 bold
style 51-51 fg=bright-blue bold
style 52-55 dim
6| " │ required matrix? │ "
style 1-1 inverse
style 4-4 fg=bright-blue
style 6-21 bold
style 51-51 fg=bright-blue
7| "────│ │────"
style 0-3 dim
style 4-4 fg=bright-blue
style 51-51 fg=bright-blue
style 52-55 dim
8| "/wor│ [ ] Code Mode — run_code programs and capt │:com"
style 0-3 dim
style 4-4 fg=bright-blue
style 6-6 fg=bright-blue inverse
style 7-20 inverse
style 21-49 fg=bright-black inverse
style 51-51 fg=bright-blue
style 52-55 dim
9| " │ [ ] Workflows — phases and parallel agents │ "
style 4-4 fg=bright-blue
style 21-49 fg=bright-black
style 51-51 fg=bright-blue
10| " │ [ ] Cordis tools — inspect, mount, and unm │ "
style 4-4 fg=bright-blue
style 24-49 fg=bright-black
style 51-51 fg=bright-blue
11| " │ 1/4 │ "
style 4-4 fg=bright-blue
style 6-8 dim
style 51-51 fg=bright-blue
12| " │ ↑↓ navigate • Space toggle • Enter submit • │ "
style 4-4 fg=bright-blue
style 6-49 dim
style 51-51 fg=bright-blue
13| " │ Select at least one option, or press C for a │ "
style 4-4 fg=bright-blue
style 6-49 fg=red
style 51-51 fg=bright-blue
14| " ╰──────────────────────────────────────────────╯ "
style 4-51 fg=bright-blue
15-19| <blank>
5| " "
6| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black
7| " Which advanced TUI states belong in the required "
8| " matrix? "
9| " "
10| " 1. [ ] Code Mode run_code programs and capture "
style 2-19 fg=bright-blue bold
style 25-53 fg=bright-black
11| " 2. [ ] Workflows phases and parallel agents "
style 25-50 fg=bright-black
12| " 3. [ ] Cordis tools inspect, mount, and unmount "
style 25-51 fg=bright-black
13| " 1/4 "
style 2-4 dim
14| " Tab custom answer • ↑/↓ navigate • Space toggle • "
style 2-55 dim
15| " Enter submit • Esc interrupt "
style 2-29 dim
16| " Select at least one option, or press Tab for a "
style 2-55 fg=red
17| " custom answer. "
style 2-15 fg=red
18| " "
19| <blank>

View File

@@ -20,48 +20,28 @@ viewport
style 55-55 fg=bright-blue
4| "╰──────────────────────────────────────────────────────╯"
style 0-55 fg=bright-blue
5| "────╭ Coverage ────────────────────────────────────────"
style 0-3 dim
style 4-51 fg=bright-blue
style 52-55 dim
6| " │ Which advanced TUI states belong in the │ "
5| "────────────────────────────────────────────────────────"
style 0-55 dim
6| " "
style 1-1 inverse
style 4-4 fg=bright-blue
style 6-50 bold
style 51-51 fg=bright-blue bold
7| "────│ required matrix? │────"
style 0-3 dim
style 4-4 fg=bright-blue
style 6-21 bold
style 51-51 fg=bright-blue
style 52-55 dim
8| "/wor│ │:com"
style 0-3 dim
style 4-4 fg=bright-blue
style 51-51 fg=bright-blue
style 52-55 dim
9| " │ [ ] Code Mode — run_code programs and capt │ "
style 4-4 fg=bright-blue
style 6-6 fg=bright-blue inverse
style 7-20 inverse
style 21-49 fg=bright-black inverse
style 51-51 fg=bright-blue
10| " │ [ ] Workflows — phases and parallel agents │ "
style 4-4 fg=bright-blue
style 21-49 fg=bright-black
style 51-51 fg=bright-blue
11| " │ [ ] Cordis tools — inspect, mount, and unm │ "
style 4-4 fg=bright-blue
style 24-49 fg=bright-black
style 51-51 fg=bright-blue
12| " │ 1/4 │ "
style 4-4 fg=bright-blue
style 6-8 dim
style 51-51 fg=bright-blue
13| " │ ↑↓ navigate • Space toggle • Enter submit • │ "
style 4-4 fg=bright-blue
style 6-49 dim
style 51-51 fg=bright-blue
14| " ╰──────────────────────────────────────────────╯ "
style 4-51 fg=bright-blue
15-19| <blank>
7| " "
8| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black
9| " Which advanced TUI states belong in the required "
10| " matrix? "
11| " "
12| " 1. [ ] Code Mode run_code programs and capture "
style 2-19 fg=bright-blue bold
style 25-53 fg=bright-black
13| " 2. [ ] Workflows phases and parallel agents "
style 25-50 fg=bright-black
14| " 3. [ ] Cordis tools inspect, mount, and unmount "
style 25-51 fg=bright-black
15| " 1/4 "
style 2-4 dim
16| " Tab custom answer • ↑/↓ navigate • Space toggle • "
style 2-55 dim
17| " Enter submit • Esc interrupt "
style 2-29 dim
18| " "
19| <blank>

View File

@@ -42,7 +42,7 @@ buffer
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
18-35| <blank>

View File

@@ -39,7 +39,7 @@ buffer
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
16-35| <blank>

View File

@@ -43,7 +43,7 @@ buffer
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
18| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
19-35| <blank>

View File

@@ -39,7 +39,7 @@ buffer
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 38-95 dim
16-35| <blank>

View File

@@ -43,7 +43,7 @@ buffer
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
18| "/workspace/project ↑0 ↓0 context unknown tools:compact mock(reasoning:on)"
style 0-24 dim
style 63-95 dim
style 46-95 dim
19-23| <blank>

View File

@@ -35,7 +35,6 @@ buffer
style 1-1 inverse
12| "────────────────────────────────────────────"
style 0-43 dim
13| "/workspace/project ↑0 ↓0 idle reasoning:o"
style 0-24 dim
style 27-43 dim
13| " 0% context deepseek-v4-flash(reasoning:on)"
style 1-43 dim
14-17| <blank>

View File

@@ -31,7 +31,7 @@ buffer
style 1-1 inverse
10| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-103 dim
11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
11| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 71-103 dim
style 46-103 dim
12-29| <blank>

View File

@@ -45,8 +45,9 @@ buffer
style 2-19 dim
15| "▌ packages/ui/tui 100% "
style 0-0 fg=green
16| "▌ 4016 tests passed "
16| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
17| "▌ 1 test skipped "
style 0-0 fg=green
18| "▌ coverage complete "
@@ -62,6 +63,6 @@ buffer
style 1-1 inverse
23| "────────────────────────────────────────────────────────────────────────────────"
style 0-79 dim
24| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 47-79 dim
24| "/workspace/pro ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-13 dim
style 22-79 dim

View File

@@ -49,36 +49,19 @@ buffer
19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-65 fg=bright-black
20| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ "
20| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-13 dim
style 14-85 fg=bright-blue
21| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ "
style 2-54 dim
21| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 14-14 fg=bright-blue
style 16-76 bold
style 85-85 fg=bright-blue
22| "▌ [signal SIG\\│ │ "
22| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-0 fg=green
style 2-13 fg=red
style 14-14 fg=bright-blue
style 85-85 fg=bright-blue
23| "▌ │ ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ "
style 2-58 fg=red
23| "▌ "
style 0-0 fg=green
style 14-14 fg=bright-blue
style 16-16 fg=bright-blue inverse
style 17-17 inverse
style 18-18 fg=bright-blue inverse
style 19-78 inverse
style 79-83 fg=bright-black inverse
style 85-85 fg=bright-blue
24| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ "
style 14-14 fg=bright-blue
style 16-65 dim
style 85-85 fg=bright-blue
25| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ "
style 1-13 dim
style 14-85 fg=bright-blue
24| <blank>
25| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-62 dim
26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-60 fg=bright-black
27| <blank>
@@ -88,19 +71,17 @@ buffer
30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
31| <blank>
32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
33| <blank>
34| "Plan"
style 0-3 fg=bright-blue bold
35| " Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 2-2 fg=yellow
36| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
37| " "
style 1-1 inverse
38| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
39| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
32| " "
33| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 2-90 fg=bright-black
34| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
35| " "
36| " 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
style 2-65 fg=bright-blue bold
style 67-97 fg=bright-black
37| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
style 2-64 dim
38| " "
39| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 67-99 dim
style 42-99 dim

View File

@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -40,6 +41,8 @@ const CHECKPOINTS = [
'surface-before-compaction',
'surface-after-compaction-narrow',
'surface-after-compaction-wide',
'model-selector',
'model-switching',
'errors-and-help',
'disposed-terminal',
] as const
@@ -102,7 +105,7 @@ async function disposeSnapshot(harness: SnapshotHarness): Promise<void> {
async function configureAdvancedTools(ctx: Context): Promise<void> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry, { mode: 'code' })
ctx.provide('workflows', {} as never)
ctx.provide('workflows', { start() {} } as never)
await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 })
await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 })
}
@@ -123,7 +126,7 @@ function appendToolCalls(session: Session, calls: readonly ToolCallFixture[]): v
for (const call of calls) {
session.append('tool/call', {
turn: 1,
step: 0,
step: 1,
callId: CallId(call.id),
name: call.name,
arguments: JSON.stringify(call.arguments),
@@ -139,7 +142,7 @@ function appendToolResult(
): void {
session.append('tool/result', {
turn: 1,
step: 0,
step: 1,
callId: CallId(id),
content,
isError: options.isError ?? false,
@@ -205,25 +208,27 @@ describe('TUI terminal-state snapshots', () => {
it('pins an in-flight reasoning and Markdown stream', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {
harness.agent.status = 'running'
harness.ctx.emit('agent/status', harness.agent, 'running')
appendUser(harness.session, 'Show the live update.')
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
harness.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' },
})
})
@@ -430,9 +435,10 @@ describe('TUI terminal-state snapshots', () => {
source: { kind: 'user' },
reason: `Unsafe policy ${CONTROL_PROBE}`,
})
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 7,
reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` },
turn: 1,
reason: { kind: 'error', step: 1, message: `Unsafe turn error ${CONTROL_PROBE}` },
})
},
}, { columns: 100, rows: 34 })
@@ -454,7 +460,7 @@ describe('TUI terminal-state snapshots', () => {
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await harness.terminal.waitForFrame(beforeQuestion)
await renderAfter(harness, () => {
harness.ctx.emit('agent/error', harness.agent, 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
agentEvents(harness.ctx, harness.agent).emit('agent/error', 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`))
})
await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true })
@@ -467,25 +473,29 @@ describe('TUI terminal-state snapshots', () => {
const harness = await setupSnapshot({
config: {
maxQuestionOptions: 3,
questionDialogWidth: 48,
questionDialogWidth: 200,
questionDialogMaxHeight: 16,
},
}, { columns: 56, rows: 20 })
const controller = new AbortController()
const beforeQuestion = harness.terminal.frames
const answer = harness.ctx.userInteraction.ask({
questions: [{
id: 'coverage',
header: 'Coverage',
question: 'Which advanced TUI states belong in the required matrix?',
multiSelect: true,
options: [
{ label: 'Code Mode', description: 'run_code programs and captured output' },
{ label: 'Workflows', description: 'phases and parallel agents' },
{ label: 'Cordis tools', description: 'inspect, mount, and unmount' },
{ label: 'Compaction', description: 'surface replacement and reflow' },
],
}],
questions: [
{
id: 'coverage',
header: 'Coverage',
question: 'Which advanced TUI states belong in the required matrix?',
multiSelect: true,
options: [
{ label: 'Code Mode', description: 'run_code programs and captured output' },
{ label: 'Workflows', description: 'phases and parallel agents' },
{ label: 'Cordis tools', description: 'inspect, mount, and unmount' },
{ label: 'Compaction', description: 'surface replacement and reflow' },
],
},
{ id: 'priority', question: 'Which state should be implemented first?' },
{ id: 'notes', question: 'Any additional constraints?' },
],
signal: controller.signal,
})
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
@@ -512,14 +522,14 @@ describe('TUI terminal-state snapshots', () => {
}, { surfaceOp: 'append' })
const assistant = session.append('assistant/message', {
turn: 1,
step: 0,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 0, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn: 1,
step: 0,
step: 1,
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
isError: false,
@@ -555,13 +565,15 @@ describe('TUI terminal-state snapshots', () => {
harness.terminal.send('\r')
harness.terminal.send('/unknown-advanced-command')
harness.terminal.send('\r')
harness.ctx.emit('agent/error', harness.agent, 3, 1, new Error('provider stream failed after partial output'))
agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output'))
harness.session.append('step/end', { turn: 1, step: 1 })
harness.session.append('turn/end', {
turn: 3,
turn: 1,
reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' },
})
harness.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
harness.session.append('turn/end', {
turn: 4,
turn: 2,
reason: { kind: 'interrupted' },
})
})
@@ -573,6 +585,21 @@ describe('TUI terminal-state snapshots', () => {
await harness.ctx.fiber.dispose()
await harness.terminal.dispose()
})
it('pins the model selector and selection notice', async () => {
const harness = await setupSnapshot({}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
harness.terminal.send('/model')
harness.terminal.send('\r')
})
await checkpoint('model-selector', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.terminal.send('\x1b[B')
harness.terminal.send('\r')
})
await checkpoint('model-switching', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
})
afterAll(async () => {

View File

@@ -3,7 +3,8 @@ import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
@@ -114,14 +115,25 @@ async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<
await disposeTuiTestHarness(setupResult)
}
function provideTokenMeter(ctx: Context): void {
ctx.provide('tokenMeter', {
measure() {
return { totalTokens: 0 }
},
} as never)
}
describe('TUI config', () => {
it('defaults every direct-call TUI option', () => {
expect(resolveTuiConfig(undefined)).toEqual({
showReasoning: true,
maxToolOutputLines: 12,
maxToolOutputLines: 6,
maxQuestionOptions: 8,
questionDialogWidth: 72,
maxModelOptions: 8,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
modelDialogMaxHeight: 20,
showHardwareCursor: false,
color: true,
title: 'DeepSeek Harness',
@@ -130,8 +142,11 @@ describe('TUI config', () => {
showReasoning: false,
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
showHardwareCursor: true,
color: false,
title: 'DSH',
@@ -139,8 +154,11 @@ describe('TUI config', () => {
showReasoning: false,
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
showHardwareCursor: true,
color: false,
title: 'DSH',
@@ -150,7 +168,11 @@ describe('TUI config', () => {
describe('pi-tui chat lifecycle and transcript', () => {
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
let now = 0
const result = await setup({
contextWindow: 100,
contextTokens: 42,
now: () => now,
beforeMount(session) {
appendUser(session, 'restored prompt')
appendAssistant(session, [
@@ -176,9 +198,19 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('restored answer')
expect(result.terminal.output).toContain('write tests')
expect(result.terminal.output).toContain('↑1.3k ↓42')
expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)')
result.terminal.resize(52)
await tick()
expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)')
result.terminal.resize(65)
await tick()
expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)')
result.terminal.resize(88)
await tick()
result.agent.status = 'running'
result.ctx.emit('agent/status', result.agent, 'running')
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
now = 8_000
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
@@ -186,62 +218,65 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
appendAssistant(result.session, [])
result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } })
result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } })
result.session.append('step/start', { turn: 11, step: 0 })
result.session.append('step/end', { turn: 1, step: 1 })
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
result.session.append('step/start', { turn: 3, step: 1 })
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 1, blockType: 'text' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'text-delta', index: 1, text: 'live answer' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
})
result.session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 3,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
})
await tick()
@@ -252,33 +287,35 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.session,
[{ type: 'text', text: 'final live answer' }],
{ inputTokens: 500, outputTokens: 8 },
{ turn: 2, step: 0 },
{ turn: 3, step: 1 },
)
await tick()
expect(result.terminal.output).toContain('Working')
expect(result.terminal.output).toContain('Working · 8s')
expect(result.terminal.output).toContain('esc interrupt')
expect(result.terminal.output).toContain('Steering')
expect(result.terminal.output).toContain('user context')
expect(result.terminal.output).toContain('Prompt blocked')
expect(result.terminal.output).toContain('Turn cancelled')
expect(result.terminal.output).toContain('final live answer')
expect(result.terminal.output).toContain('↑1.8k ↓50')
expect(result.terminal.progress).toContain(true)
result.session.append('assistant/chunk', {
turn: 3,
step: 0,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'cleared stream' },
})
result.terminal.send('/clear')
result.terminal.send('\r')
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }])
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 })
await tick()
expect(result.terminal.output).toContain('answer after clear')
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
await tick()
expect(result.terminal.output).toContain('↑1.8k ↓50')
expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
expect(result.terminal.stopped).toBe(1)
@@ -395,8 +432,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
appendUser(session, 'first prompt')
appendUser(session, 'second prompt')
session.append('assistant/chunk', {
turn: 2,
step: 0,
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
})
},
@@ -448,6 +485,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
result.agent.status = 'running'
result.ctx.emit('agent/status', result.agent, 'running')
result.terminal.send('steer it')
result.terminal.send('\r')
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]])
@@ -789,6 +827,189 @@ describe('pi-tui chat lifecycle and transcript', () => {
await lateSuccess.ctx.fiber.dispose()
})
it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
const initialContext = Promise.withResolvers<{ contextWindow: number }>()
const result = await setup({
agentOptions: { provider: 'alpha', model: 'a1' },
contextTokens: 50,
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
models: [
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
{ provider: 'alpha', id: 'shared', name: 'Alpha Shared' },
{ provider: 'beta', id: 'b1', name: 'Beta One' },
{ provider: 'beta', id: 'shared', name: 'Beta Shared' },
],
resolveModelContext: (provider, model) => provider === 'alpha' && model === 'a1'
? initialContext.promise
: Promise.resolve({ contextWindow: 200 }),
},
})
for (const command of ['/model too many model arguments', '/model missing', '/model shared', '/model alpha/a1', '/model alpha a1']) {
result.terminal.send(command)
result.terminal.send('\r')
await tick()
}
expect(result.terminal.output).toContain('Usage: /model')
expect(result.terminal.output).toContain('Unknown model: missing')
expect(result.terminal.output).toContain('advertised by multiple providers')
expect(result.terminal.output).toContain('already alpha/a1')
result.agent.status = 'running'
result.terminal.send('/model')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Select model')
expect(result.terminal.output).toContain('alpha/a1')
expect(result.terminal.output).toContain('Alpha One — Fast — current')
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[B')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Model selected: beta/b1')
expect(result.agent.sent).toEqual([])
expect(result.agent.steered).toEqual([])
initialContext.resolve({ contextWindow: 100 })
await tick()
expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)')
result.terminal.send('/model')
result.terminal.send('\r')
await tick()
result.terminal.send('\x1b')
await tick()
expect(result.agent.cancelled).not.toContain('cancelled from terminal')
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
await tick()
expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)')
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 }
const request = await agentEvents(result.ctx, result.agent).waterfall(
'agent/request', 1, 0, seed, () => Promise.resolve(seed),
)
expect(request).toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
await dispose(result)
})
it('restores the logged model, keeps an unlisted current model visible, and reports catalog failures', async () => {
const resumed = await setup({
agentOptions: { provider: 'alpha', model: 'configured' },
catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] },
beforeMount(session) {
session.append('request/header', {
header: { config: { provider: 'beta', model: 'private' } },
reason: 'initial',
})
},
})
resumed.terminal.send('/model')
resumed.terminal.send('\r')
await tick()
expect(resumed.terminal.output).toContain('Select model')
expect(resumed.terminal.output).toContain('beta/private')
expect(resumed.terminal.output).toContain('private — current')
await dispose(resumed)
const unset = await setup({
agentOptions: {},
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
resolveModelContext: () => Promise.resolve(undefined),
},
})
unset.terminal.send('/model')
unset.terminal.send('\r')
await tick()
unset.terminal.send('\r')
await tick()
expect(unset.terminal.output).toContain('Model selected: alpha/a1')
expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)')
await dispose(unset)
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
empty.terminal.send('/model')
empty.terminal.send('\r')
await tick()
expect(empty.terminal.output).toContain('Current model: unset')
expect(empty.terminal.output).toContain('No models are advertised')
const assembly = await empty.ctx.systemPrompt.assemble(assembleContextFor(empty.agent))
expect(assembly.variables).toEqual({})
const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' }
await expect(agentEvents(empty.ctx, empty.agent).waterfall(
'agent/request', 1, 0, seed, () => Promise.resolve(seed),
)).resolves.toBe(seed)
await dispose(empty)
const failed = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
listModels: () => Promise.reject(new Error('catalog offline')),
resolveModelContext: () => Promise.reject(new Error('capacity offline')),
},
})
failed.terminal.send('/model')
failed.terminal.send('\r')
await tick()
expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
await dispose(failed)
})
it('does not render a model catalog that resolves after TUI disposal', async () => {
const deferred = Promise.withResolvers<never[]>()
const result = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
listModels: () => deferred.promise,
},
})
result.terminal.send('/model')
result.terminal.send('\r')
await result.controller.dispose()
deferred.resolve([])
await tick()
expect(result.terminal.output).not.toContain('Available models')
await result.ctx.fiber.dispose()
const rejected = Promise.withResolvers<never[]>()
const rejectedResult = await setup({
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
listModels: () => rejected.promise,
},
})
rejectedResult.terminal.send('/model')
rejectedResult.terminal.send('\r')
await rejectedResult.controller.dispose()
rejected.reject(new Error('late catalog failure'))
await tick()
expect(rejectedResult.terminal.output).not.toContain('late catalog failure')
await rejectedResult.ctx.fiber.dispose()
const context = Promise.withResolvers<{ contextWindow: number }>()
const contextResult = await setup({
contextTokens: 99,
catalog: {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
models: [],
resolveModelContext: () => context.promise,
},
})
await contextResult.controller.dispose()
context.resolve({ contextWindow: 100 })
await tick()
expect(contextResult.terminal.output).not.toContain('99% context')
await contextResult.ctx.fiber.dispose()
})
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
const result = await setup()
const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
@@ -902,22 +1123,30 @@ describe('pi-tui chat lifecycle and transcript', () => {
const events = await setup()
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
events.ctx.emit('agent/status', unrelatedAgent, 'running')
events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error'))
events.ctx.emit('agent/disposed', unrelatedAgent)
events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure'))
events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } })
events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } })
events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted', reason: 'stopped' } })
events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } })
events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } })
agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error'))
agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
events.session.append('step/end', { turn: 1, step: 1 })
events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } })
events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } })
events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'stopped' } })
events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 5, reason: { kind: 'rejected', reason: 'policy' } })
events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', { turn: 6, reason: { kind: 'interrupted' } })
events.session.append('turn/start', { turn: 7, trigger: { kind: 'message', source: { kind: 'user' } } })
events.session.append('turn/end', {
turn: 9,
turn: 7,
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
})
events.ctx.emit('agent/disposed', events.agent)
agentEvents(events.ctx, events.agent).emit('agent/disposed')
await tick()
expect(events.terminal.output).toContain('live failure')
expect(events.terminal.output).toContain('durable failure')
@@ -990,7 +1219,7 @@ describe('tool cards and surface replay', () => {
}
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
const result = await setup({ tools, config: { maxToolOutputLines: 1 } })
const result = await setup({ tools, config: { maxToolOutputLines: 4 } })
const calls = [
['c1', 'bash', '{"command":"printf hello"}'],
['c2', 'signal', '{}'],
@@ -1011,7 +1240,7 @@ describe('tool cards and surface replay', () => {
})),
])
for (const [id, name, args] of calls) {
result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args })
result.session.append('tool/call', { turn: 1, step: 1, callId: id as never, name, arguments: args })
}
await tick()
expect(result.terminal.output).toContain('$ raw command')
@@ -1021,23 +1250,23 @@ describe('tool cards and surface replay', () => {
expect(result.terminal.output).toContain('call presenter boom')
expect(result.terminal.output).toContain('Symbol(input)')
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
turn: 1, step: 1, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
turn: 1, step: 1, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
turn: 1, step: 1, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
turn: 1, step: 1, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
meta: { value: 1 },
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c7' as never,
turn: 1, step: 1, callId: 'c7' as never,
content: [
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
@@ -1046,20 +1275,25 @@ describe('tool cards and surface replay', () => {
isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
turn: 1, step: 1, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
turn: 1, step: 1, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
}, { surfaceOp: 'append' })
result.session.append('tool/result', {
turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false,
turn: 1,
step: 1,
callId: 'orphan' as never,
content: [{ type: 'text', text: 'orphan result' }],
isError: true,
error: { name: 'InterruptedError', code: 'interrupted' },
}, { surfaceOp: 'append' })
await tick()
const output = result.terminal.output
expect(output).toContain('Run command')
expect(output).toContain('printf hello')
expect(output).toContain('more lines')
expect(output).toContain('lines (Ctrl+O to expand)')
expect(output).toContain('SIGTERM')
expect(output).toContain('Edit files')
expect(output).toContain('Inspected')
@@ -1076,6 +1310,11 @@ describe('tool cards and surface replay', () => {
result.terminal.send('/redraw')
result.terminal.send('\r')
await tick()
const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(collapsed).toContain('Run command')
expect(collapsed).toContain('[exit 0]')
expect(collapsed).not.toContain('▌ hello')
expect(collapsed).not.toContain('world')
result.terminal.send('\x0f')
await tick()
expect(result.terminal.output).toContain('world')
@@ -1088,15 +1327,15 @@ describe('tool cards and surface replay', () => {
appendUser(result.session, 'old prompt')
const assistant = result.session.append('assistant/message', {
turn: 1,
step: 0,
step: 1,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
result.session.append('tool/call', {
turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}',
turn: 1, step: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}',
})
const toolResult = result.session.append('tool/result', {
turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
turn: 1, step: 1, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
}, { surfaceOp: 'append' })
const start = result.session.surface.nodes[0] as number
result.session.append('context/message', {
@@ -1129,6 +1368,7 @@ describe('TUI user-interaction dialogs', () => {
})
await tick()
expect(result.terminal.output).toContain('Choose a mode')
expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · Mode')
expect(result.terminal.output).toContain('1/2')
result.terminal.send('\x1b[B')
result.terminal.send('\r')
@@ -1148,7 +1388,7 @@ describe('TUI user-interaction dialogs', () => {
questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
})
await tick()
result.terminal.send('c')
result.terminal.send('\t')
result.terminal.send('my choice')
result.terminal.send('\r')
await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] })
@@ -1222,9 +1462,11 @@ describe('TUI user-interaction dialogs', () => {
],
})
await tick()
expect(result.terminal.output).toContain('Question 1/2 (2 unanswered)')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Second?')
expect(result.terminal.output).toContain('Question 2/2 (1 unanswered)')
result.terminal.send('done')
result.terminal.send('\r')
await expect(batch).resolves.toEqual({ answers: [
@@ -1271,6 +1513,7 @@ describe('TUI user-interaction dialogs', () => {
describe('terminal mounting', () => {
it('starts immediately when the configured agent already exists', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
@@ -1290,6 +1533,7 @@ describe('terminal mounting', () => {
it('waits for its configured agent before starting the TUI', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
@@ -1319,6 +1563,7 @@ describe('terminal mounting', () => {
it('prints a matching live startup failure and exits instead of waiting forever', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
@@ -1347,6 +1592,7 @@ describe('terminal mounting', () => {
it('renders an uncoercible startup failure without escaping the display boundary', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
@@ -1368,12 +1614,15 @@ describe('terminal mounting', () => {
it('rolls back providers, listeners, and terminal state when startup fails', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('failed-start-session'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
ctx.agents.register({
id: session.id, options: {}, session, status: 'running', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
@@ -1391,7 +1640,7 @@ describe('terminal mounting', () => {
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
session.append('assistant/chunk', {
turn: 1,
step: 0,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'must not render' },
})
await tick()
@@ -1401,6 +1650,7 @@ describe('terminal mounting', () => {
it('throws when createTuiChat is called without the configured agent', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)