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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../commands"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user