mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge origin/master into worktree-windows-runtime
# Conflicts: # .agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md # packages/support/acp-snapshot/src/harness.ts # packages/support/acp-snapshot/src/normalize.ts # packages/support/acp-snapshot/tests/harness.spec.ts # packages/support/acp-snapshot/tests/normalize.spec.ts
This commit is contained in:
@@ -35,6 +35,7 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
@@ -55,7 +56,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'ui-tui'
|
||||
export const inject = ['agents', 'userInteraction', 'tools']
|
||||
export const inject = ['agents', 'commands', 'userInteraction', 'tools']
|
||||
|
||||
/** Presentation settings for the pi-tui terminal mode. */
|
||||
export interface TuiConfig {
|
||||
@@ -865,6 +866,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
|
||||
|
||||
const welcome = config.welcome ?? 'ready.'
|
||||
@@ -1146,6 +1148,8 @@ export function createTuiChat(
|
||||
shuttingDown ??= (async () => {
|
||||
disposed = true
|
||||
clearStatus()
|
||||
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
|
||||
commandControllers.clear()
|
||||
if (activeQuestion !== undefined) {
|
||||
const pending = activeQuestion
|
||||
activeQuestion = undefined
|
||||
@@ -1170,16 +1174,6 @@ export function createTuiChat(
|
||||
void shutdown(true)
|
||||
}
|
||||
|
||||
editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
|
||||
{ name: 'help', description: 'Show keyboard shortcuts and commands' },
|
||||
{ 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)
|
||||
@@ -1199,52 +1193,107 @@ 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 expand tool cards • Ctrl+R toggle reasoning',
|
||||
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
|
||||
'/help /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: '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('')
|
||||
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 (value.startsWith('/')) {
|
||||
runCommand(value)
|
||||
return
|
||||
}
|
||||
if (agent.status === 'disposed') {
|
||||
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
||||
@@ -1321,6 +1370,7 @@ export function createTuiChat(
|
||||
|
||||
const detachListeners = (): void => {
|
||||
removeInputListener()
|
||||
disposeCommandChanges()
|
||||
disposeSessionEvents()
|
||||
disposeStatus()
|
||||
disposeError()
|
||||
@@ -1334,6 +1384,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()
|
||||
@@ -1344,6 +1400,7 @@ export function createTuiChat(
|
||||
async dispose(): Promise<void> {
|
||||
detachListeners()
|
||||
await shutdown(false)
|
||||
await commandFiber.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user