Merge remote-tracking branch 'origin/master' into fix/tui-color-scheme-v2
Resolve conflicts: - packages/ui/tui/src/index.ts: keep the color-scheme detection block; drop the obsolete static autocomplete list (master moved to refreshCommandAutocomplete). - docs/config-catalog.md: regenerate (tui Config source line shifted to :104).
This commit is contained in:
+155
-61
@@ -36,8 +36,10 @@ 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 } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
FileDiff,
|
||||
@@ -55,7 +57,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 {
|
||||
@@ -609,15 +611,38 @@ function formatCwd(cwd: string | undefined): string {
|
||||
return displayText(cwd)
|
||||
}
|
||||
|
||||
function sessionTokens(session: Session): { input: number; output: number } {
|
||||
let input = 0
|
||||
let output = 0
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'assistant/message' || event.data.usage === undefined) continue
|
||||
input += event.data.usage.inputTokens
|
||||
output += event.data.usage.outputTokens
|
||||
interface SessionTokenTotals {
|
||||
input: number
|
||||
output: number
|
||||
readonly byStep: Map<string, TokenUsage>
|
||||
}
|
||||
|
||||
function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void {
|
||||
const key = `${turn}:${step}`
|
||||
const previous = totals.byStep.get(key)
|
||||
if (previous !== undefined) {
|
||||
totals.input -= previous.inputTokens
|
||||
totals.output -= previous.outputTokens
|
||||
}
|
||||
return { input, output }
|
||||
totals.byStep.set(key, usage)
|
||||
totals.input += usage.inputTokens
|
||||
totals.output += usage.outputTokens
|
||||
}
|
||||
|
||||
function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void {
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
|
||||
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage)
|
||||
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
||||
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage)
|
||||
}
|
||||
}
|
||||
|
||||
function sessionTokens(session: Session): SessionTokenTotals {
|
||||
const totals: SessionTokenTotals = { input: 0, output: 0, byStep: new Map() }
|
||||
for (const event of session.events) {
|
||||
recordEventUsage(totals, event)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
class FooterComponent implements Component {
|
||||
@@ -836,6 +861,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.'
|
||||
@@ -896,6 +922,14 @@ export function createTuiChat(
|
||||
return card
|
||||
}
|
||||
|
||||
const clearStreaming = (): void => {
|
||||
if (streaming === undefined) return
|
||||
const index = chat.children.indexOf(streaming)
|
||||
/* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */
|
||||
if (index >= 0) chat.children.splice(index, 1)
|
||||
streaming = undefined
|
||||
}
|
||||
|
||||
const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
@@ -938,15 +972,19 @@ export function createTuiChat(
|
||||
}
|
||||
break
|
||||
case 'assistant/message': {
|
||||
if (streaming !== undefined) {
|
||||
const index = chat.children.indexOf(streaming)
|
||||
if (index >= 0) chat.children.splice(index, 1)
|
||||
streaming = undefined
|
||||
}
|
||||
clearStreaming()
|
||||
const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme)
|
||||
if (component.children.length > 0) chat.addChild(component)
|
||||
break
|
||||
}
|
||||
case 'llm/retry': {
|
||||
clearStreaming()
|
||||
appendNotice(
|
||||
`Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`,
|
||||
'warning',
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'tool/call':
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(parsedTool(event))
|
||||
@@ -967,9 +1005,13 @@ export function createTuiChat(
|
||||
todo.update(event.data.todos)
|
||||
break
|
||||
case 'turn/end':
|
||||
clearStreaming()
|
||||
if (event.data.reason.kind === 'error') {
|
||||
const key = `${event.data.turn}:${event.data.reason.step}`
|
||||
if (!liveErrors.delete(key)) appendNotice(event.data.reason.message, 'error')
|
||||
const message = 'failure' in event.data.reason
|
||||
? event.data.reason.failure.message
|
||||
: event.data.reason.message
|
||||
if (!liveErrors.delete(key)) appendNotice(message, 'error')
|
||||
} else if (event.data.reason.kind === 'aborted') {
|
||||
appendNotice(event.data.reason.reason ?? 'Turn cancelled.', 'warning')
|
||||
} else if (event.data.reason.kind === 'max-tokens') {
|
||||
@@ -1094,6 +1136,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
|
||||
@@ -1118,16 +1162,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()))
|
||||
|
||||
/** Swap the palette and all derived themes for the given terminal color scheme. */
|
||||
const applyColorScheme = (scheme: TerminalColorScheme): void => {
|
||||
if (scheme === currentScheme) return
|
||||
@@ -1171,52 +1205,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')
|
||||
@@ -1266,10 +1355,7 @@ export function createTuiChat(
|
||||
|
||||
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
||||
tokens.input += event.data.usage.inputTokens
|
||||
tokens.output += event.data.usage.outputTokens
|
||||
}
|
||||
recordEventUsage(tokens, event)
|
||||
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
|
||||
rebuildTranscript(false)
|
||||
return
|
||||
@@ -1296,6 +1382,7 @@ export function createTuiChat(
|
||||
|
||||
const detachListeners = (): void => {
|
||||
removeInputListener()
|
||||
disposeCommandChanges()
|
||||
disposeSessionEvents()
|
||||
disposeStatus()
|
||||
disposeError()
|
||||
@@ -1310,6 +1397,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()
|
||||
@@ -1320,6 +1413,7 @@ export function createTuiChat(
|
||||
async dispose(): Promise<void> {
|
||||
detachListeners()
|
||||
await shutdown(false)
|
||||
await commandFiber.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user