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

# Conflicts:
#	docs/config-catalog.md
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	packages/ui/acp/src/index.ts
#	packages/ui/tui/README.md
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/plugin-shape.spec.ts
#	packages/ui/tui/tests/snapshots/disposed-terminal.expected.txt
#	packages/ui/tui/tests/snapshots/errors-and-help.expected.txt
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
Yichen Jiang
2026-07-21 10:15:49 +08:00
262 changed files with 12998 additions and 271 deletions

View File

@@ -6,13 +6,13 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
This package owns interactive terminal presentation and input only. It injects `agents`, `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view shows token-meter context occupancy, tool-card mode, and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
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, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. 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. `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide terminal-only controls without entering command text into the session.
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`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. 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.
@@ -44,7 +44,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t
maxToolOutputLines: 6
```
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
## Color
@@ -56,7 +56,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
#### What the model sees
Each non-empty non-command editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash command text and keybindings are TUI-only.
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices.
#### Token effect

View File

@@ -24,6 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -41,6 +42,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -42,6 +42,7 @@ import {
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-token-meter'
import type {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type {
ContentBlock,
@@ -67,7 +68,7 @@ import {
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-tui'
export const inject = ['agents', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
/** Presentation settings for the pi-tui terminal mode. */
export interface TuiConfig {
@@ -1053,6 +1054,7 @@ export function createTuiChat(
const allToolCards = new Set<ToolCardComponent>()
const liveErrors = new Set<string>()
const questionQueue: PendingQuestion[] = []
const commandControllers = new Set<AbortController>()
let activeQuestion: PendingQuestion | undefined
let modelOverlay: OverlayHandle | undefined
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
@@ -1433,6 +1435,8 @@ export function createTuiChat(
clearStatus()
modelOverlay?.hide()
modelOverlay = undefined
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
commandControllers.clear()
if (activeQuestion !== undefined) {
const pending = activeQuestion
activeQuestion = undefined
@@ -1457,17 +1461,6 @@ export function createTuiChat(
void shutdown(true)
}
editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
{ name: 'help', description: 'Show keyboard shortcuts and commands' },
{ name: 'model', description: 'Show or switch this session\'s model' },
{ name: 'clear', description: 'Clear the transcript view (session history is unchanged)' },
{ name: 'cancel', description: 'Cancel the active turn' },
{ name: 'reasoning', description: 'Toggle reasoning blocks' },
{ name: 'tools', description: 'Expand or collapse all tool cards' },
{ name: 'redraw', description: 'Invalidate components and redraw the terminal' },
{ name: 'exit', description: 'Exit after the active turn reaches idle' },
], agent.session.header.cwd ?? process.cwd()))
const toggleTools = (): void => {
toolsExpanded = !toolsExpanded
for (const card of allToolCards) card.setExpanded(toolsExpanded)
@@ -1487,57 +1480,117 @@ export function createTuiChat(
}
const showHelp = (): void => {
const commandLines = ctx.commands.list(agent).map((command) => {
const input = command.input === undefined ? '' : ` ${command.input.hint}`
return `/${command.name}${input} — ${command.description}`
})
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
chat.addChild(new Text([
'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning',
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
'/help /model /clear /cancel /reasoning /tools /redraw /exit',
'',
...commandLines,
].map(line => palette.muted(line)).join('\n'), 1, 0))
requestRender()
}
const refreshCommandAutocomplete = (): void => {
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
ctx.commands.list(agent).map(command => ({
name: command.name,
description: command.description,
})),
agent.session.header.cwd ?? process.cwd(),
))
}
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
refreshCommandAutocomplete()
// The agent scope is minted by agent-loop and intentionally inherits only
// that core plugin's dependencies. A child command producer declares its own
// UI-service dependency while retaining the parent agent scope and lifetime.
const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'help',
description: 'Show keyboard shortcuts and commands',
handler: () => { showHelp(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'model',
description: 'Show or switch this session\'s model',
input: { hint: '[[provider/]model]' },
handler: ({ rawInput }) => {
queueModelCommand(rawInput)
return { kind: 'success' }
},
})
commandCtx.commands.register({
name: 'clear',
description: 'Clear the transcript view (session history is unchanged)',
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'cancel',
description: 'Cancel the active turn',
handler: () => {
if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
agent.cancel('cancelled from terminal')
return { kind: 'success', text: 'Cancellation requested.' }
},
})
commandCtx.commands.register({
name: 'reasoning',
description: 'Toggle reasoning blocks',
handler: () => { toggleReasoning(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'tools',
description: 'Expand or collapse all tool cards',
handler: () => { toggleTools(); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'redraw',
description: 'Invalidate components and redraw the terminal',
handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
})
commandCtx.commands.register({
name: 'exit',
description: 'Exit after the active turn reaches idle',
handler: () => { requestExit(); return { kind: 'success' } },
})
})
const runCommand = (text: string): void => {
const controller = new AbortController()
commandControllers.add(controller)
void ctx.commands.execute(agent, text, controller.signal).then(
(result) => {
if (disposed) return
if (result === undefined) {
appendNotice(`Unknown command: ${text}`, 'warning')
} else if (result.text !== undefined && result.text !== '') {
appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
}
},
(error: unknown) => {
if (!disposed) {
appendNotice(`Command failed: ${errorChain(error)}`, 'error')
}
},
).finally(() => { commandControllers.delete(controller) })
}
editor.onSubmit = (value: string) => {
const text = value.trim()
if (text === '') return
editor.addToHistory(text)
editor.setText('')
if (/^\/model(?:\s|$)/u.test(text)) {
queueModelCommand(text.slice('/model'.length))
if (value.startsWith('/')) {
runCommand(value)
return
}
switch (text) {
case '/help':
showHelp()
return
case '/clear':
chat.clear()
requestRender()
return
case '/cancel':
if (agent.status === 'running') agent.cancel('cancelled from terminal')
else appendNotice('The agent is already idle.')
return
case '/reasoning':
toggleReasoning()
return
case '/tools':
toggleTools()
return
case '/redraw':
ui.invalidate()
ui.requestRender(true)
return
case '/exit':
requestExit()
return
default:
if (text.startsWith('/')) {
appendNotice(`Unknown command: ${text}`, 'warning')
return
}
}
if (agent.status === 'disposed') {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') {
@@ -1613,6 +1666,7 @@ export function createTuiChat(
const detachListeners = (): void => {
removeInputListener()
disposeCommandChanges()
disposeSessionEvents()
disposeStatus()
disposeError()
@@ -1627,6 +1681,12 @@ export function createTuiChat(
} catch (error: unknown) {
disposed = true
detachListeners()
void commandFiber.dispose().catch(
/* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
(cleanupError: unknown) => {
ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
},
)
clearStatus()
disposeUserInteraction()
ui.stop()
@@ -1637,6 +1697,7 @@ export function createTuiChat(
async dispose(): Promise<void> {
detachListeners()
await shutdown(false)
await commandFiber.dispose()
},
}
}

View File

@@ -2,6 +2,7 @@ import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { type Agent, type AgentOptions, type AgentStatus } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
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'
@@ -57,6 +58,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
const catalog = options.catalog ?? {
providers: [{ id: 'deepseek', name: 'DeepSeek' }],

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', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'])
expect(unwrapped.inject).toEqual([
'agents',
'commands',
'userInteraction',
'tools',
'llm',
'systemPrompt',
'tokenMeter',
])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})

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=22 bufferRow=22
cursor visible column=0 viewportRow=30 bufferRow=30
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -29,24 +29,39 @@ buffer
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
10| " /help /model /clear /cancel /reasoning /tools /redraw /exit "
style 1-59 fg=bright-black
11| <blank>
12| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
13| <blank>
14| " provider stream failed after partial output "
10| " "
11| " /cancel — Cancel the active turn "
style 1-32 fg=bright-black
12| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
13| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
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
17| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| <blank>
20| " provider stream failed after partial output "
style 1-43 fg=red
15| <blank>
16| " The previous process ended during this turn. "
21| <blank>
22| " The previous process ended during this turn. "
style 1-44 fg=yellow
17| "────────────────────────────────────────────────────────────────────────────────────────────"
23| <blank>
24| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
25| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
18| " "
26| " "
style 1-1 inverse
19| "────────────────────────────────────────────────────────────────────────────────────────────"
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 34-91 dim
21-31| <blank>
29-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=18 bufferRow=18
cursor hidden column=1 viewportRow=26 bufferRow=26
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
@@ -29,24 +29,39 @@ buffer
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
10| " /help /model /clear /cancel /reasoning /tools /redraw /exit "
style 1-59 fg=bright-black
11| <blank>
12| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
13| <blank>
14| " provider stream failed after partial output "
10| " "
11| " /cancel — Cancel the active turn "
style 1-32 fg=bright-black
12| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
13| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
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
17| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| <blank>
20| " provider stream failed after partial output "
style 1-43 fg=red
15| <blank>
16| " The previous process ended during this turn. "
21| <blank>
22| " The previous process ended during this turn. "
style 1-44 fg=yellow
17| "────────────────────────────────────────────────────────────────────────────────────────────"
23| <blank>
24| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
25| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
18| " "
26| " "
style 1-1 inverse
19| "────────────────────────────────────────────────────────────────────────────────────────────"
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 34-91 dim
21-31| <blank>
29-31| <blank>

View File

@@ -5,6 +5,7 @@ import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
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 } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -691,6 +692,108 @@ describe('pi-tui chat lifecycle and transcript', () => {
await rejectedResult.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) => ({
kind: 'success' as const,
text: `PLUGIN:${rawInput}`,
}))
result.ctx.commands.register({
name: 'plugin-check',
description: 'Run a plugin command',
input: { hint: '<value>' },
handler,
})
result.ctx.commands.register({
name: 'plugin-fail',
description: 'Fail a plugin command',
handler: () => { throw new Error('plugin command exploded') },
})
result.terminal.send('/plugin-check value ')
result.terminal.send('\r')
await tick()
expect(handler).toHaveBeenCalledTimes(1)
const invocation = handler.mock.calls[0]?.[0]
expect(invocation?.agent).toBe(result.agent)
// pi-tui's Editor owns terminal-line normalization and removes trailing
// spaces before onSubmit; the registry preserves the adapter-delivered line.
expect(invocation?.rawInput).toBe(' value')
expect(result.terminal.output).toContain('PLUGIN: value')
result.terminal.send('/plugin-fail')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Command failed: plugin command exploded')
result.terminal.send('/help')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('/plugin-check <value> — Run a plugin command')
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')
await result.controller.dispose()
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
'plugin-check',
'plugin-fail',
])
await result.ctx.fiber.dispose()
})
it('aborts an in-flight plugin command during TUI disposal', async () => {
const result = await setup()
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
let commandSignal: AbortSignal | undefined
result.ctx.commands.register({
name: 'wait-plugin',
description: 'Wait until disposal',
handler: ({ signal }) => {
commandSignal = signal
started()
return new Promise((resolve) => {
signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true })
})
},
})
result.terminal.send('/wait-plugin')
result.terminal.send('\r')
await ready
await result.controller.dispose()
expect(commandSignal?.aborted).toBe(true)
expect(result.terminal.output).not.toContain('late result')
await result.ctx.fiber.dispose()
})
it('suppresses a successful plugin result that settles as TUI disposal starts', async () => {
const result = await setup()
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
let resolveCommand!: (result: { kind: 'success'; text: string }) => void
result.ctx.commands.register({
name: 'late-success',
description: 'Resolve while the TUI closes',
handler: () => new Promise((resolve) => {
resolveCommand = resolve
started()
}),
})
result.terminal.send('/late-success')
result.terminal.send('\r')
await ready
resolveCommand({ kind: 'success', text: 'must not render after disposal' })
// Let the command boundary accept the result before disposal, but leave the
// TUI continuation queued so the success-side disposal guard owns the race.
await Promise.resolve()
await result.controller.dispose()
await tick()
expect(result.terminal.output).not.toContain('must not render after disposal')
await result.ctx.fiber.dispose()
})
it('cancels before /exit while running and handles agent errors/disposal', async () => {
const result = await setup({ status: 'running' })
result.terminal.send('/exit')
@@ -1082,6 +1185,7 @@ describe('terminal mounting', () => {
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('main'))
@@ -1101,6 +1205,7 @@ describe('terminal mounting', () => {
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 terminal = new FakeTerminal()
@@ -1130,6 +1235,7 @@ describe('terminal mounting', () => {
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 terminal = new FakeTerminal()
@@ -1158,6 +1264,7 @@ describe('terminal mounting', () => {
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 terminal = new FakeTerminal()
@@ -1179,6 +1286,7 @@ describe('terminal mounting', () => {
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'))
@@ -1191,6 +1299,8 @@ describe('terminal mounting', () => {
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
.toThrow('terminal startup failed')
await tick()
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
expect(terminal.stopped).toBe(1)
expect(terminal.progress).toEqual([false, true, false])
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
@@ -1209,6 +1319,7 @@ describe('terminal mounting', () => {
const ctx = new Context()
provideTokenMeter(ctx)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }

View File

@@ -35,6 +35,9 @@
{
"path": "../../core/tools"
},
{
"path": "../commands"
},
{
"path": "../user-interaction"
}