Files
deepseek-harness/packages/ui/tui/src/index.ts
Tianyi Cui 6646ef04ac Merge pull request #335 from deepseek-harness/worktree-windows-runtime
feat(windows): extend runtime and test portability
2026-07-22 10:30:32 +08:00

1844 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Interactive pi-tui front door for DeepSeek Harness agents. It renders the
* durable session transcript, drives one configured agent, and provides
* keyboard-driven user-interaction dialogs without owning agent lifecycle.
* @module @deepseek-ai/dsh-tui
*/
import { homedir } from 'node:os'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import {
CombinedAutocompleteProvider,
Container,
Editor,
Input,
Key,
Markdown,
Spacer,
Text,
TUI,
ProcessTerminal,
SelectList,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
type Component,
type EditorTheme,
type Focusable,
type MarkdownTheme,
type OverlayHandle,
type SelectListTheme,
type Terminal,
type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import z from 'schemastery'
import {
installAgentLlmTarget,
type Agent,
type AgentLlmTarget,
type AgentLlmTargetRef,
type AgentStatus,
} 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,
LlmModelInfo,
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 { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
FileDiff,
TerminalCallView,
ToolCallView,
ToolDefinition,
ToolResultView,
} from '@deepseek-ai/dsh-tools'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionAnswerItem,
type AskUserQuestionItem,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-tui'
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
/** Presentation settings for the pi-tui terminal mode. */
export interface TuiConfig {
/** Render model reasoning blocks. */
showReasoning?: boolean
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
maxToolOutputLines?: number
/** Maximum options visible at once in a user-question panel. */
maxQuestionOptions?: number
/** Maximum models visible at once in the model selector. */
maxModelOptions?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question panel maximum height in terminal rows. */
questionDialogMaxHeight?: number
/** Model-selector width in terminal columns. */
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
showHardwareCursor?: boolean
/** Apply the built-in ANSI color palette. */
color?: boolean
/** Terminal window title while the UI is mounted. */
title?: string
}
const showReasoningSchema = z.boolean().default(true)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const showHardwareCursorSchema = z.boolean().default(false)
const colorSchema = z.boolean().default(true)
const titleSchema = z.string().default('DeepSeek Harness')
/** Schemastery schema for presentation settings embedded by app bundles. */
export const TuiConfigSchema: z<TuiConfig> = z.object({
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
showHardwareCursor: showHardwareCursorSchema,
color: colorSchema,
title: titleSchema,
})
/** Serializable plugin configuration. */
export interface Config extends TuiConfig {
/** Header subtitle. Defaults to `ready.`. */
welcome?: string
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
sessionId?: string
}
export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
sessionId: z.string().default('main'),
showReasoning: showReasoningSchema,
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
showHardwareCursor: showHardwareCursorSchema,
color: colorSchema,
title: titleSchema,
})
/** Fully defaulted TUI presentation settings. */
export interface ResolvedTuiConfig {
showReasoning: boolean
maxToolOutputLines: number
maxQuestionOptions: number
maxModelOptions: number
questionDialogWidth: number
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
showHardwareCursor: boolean
color: boolean
title: string
}
/** Runtime boundary used by the interactive TUI. */
export interface TuiRuntime {
/** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
terminal: Terminal
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
exit(code: number): void
/**
* Override the footer's logical working-directory label without changing the session directory used by tools.
* @param cwd - Operational working directory from the session header.
* @returns Unescaped label; the TUI makes terminal controls visible.
*/
formatCwd?: (cwd: string | undefined) => string
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
now?(): number
}
/**
* Apply direct-call defaults after Loader schema validation has normally run.
*
* @param config - Deployment-provided terminal presentation settings.
* @returns Complete settings consumed by the TUI renderer.
*/
export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
return {
showReasoning: config?.showReasoning ?? true,
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
maxModelOptions: config?.maxModelOptions ?? 8,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 72,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
showHardwareCursor: config?.showHardwareCursor ?? false,
color: config?.color ?? true,
title: config?.title ?? 'DeepSeek Harness',
}
}
interface Palette {
accent: (text: string) => string
accent2: (text: string) => string
text: (text: string) => string
muted: (text: string) => string
dim: (text: string) => string
success: (text: string) => string
warning: (text: string) => string
error: (text: string) => string
code: (text: string) => string
added: (text: string) => string
removed: (text: string) => string
bold: (text: string) => string
italic: (text: string) => string
underline: (text: string) => string
strike: (text: string) => string
/** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
selected: (text: string) => string
}
function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
}
const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu
/**
* Escape external C0/C1 controls before pi-tui adds application-owned ANSI.
* Line feeds remain structural so transcript and tool output retain their layout.
*/
function displayText(text: string): string {
return text.replace(TERMINAL_CONTROL_PATTERN, control =>
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
}
/**
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
* attributes, which every terminal remaps to its active color scheme. Body
* `text` stays the terminal's default foreground so it reads on light and dark
* backgrounds alike; grouping uses foreground-only gutter bars and reverse
* video rather than fixed background fills.
*/
function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
return {
accent: ansi('94', '39', enabled),
accent2: ansi('95', '39', enabled),
text: text => text,
muted: ansi('90', '39', enabled),
// SGR 2 (dim) lightens text on a light background — substitute ANSI 90
// (bright black / gray) which renders as a readable muted tone on any scheme.
dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
success: ansi('32', '39', enabled),
warning: ansi('33', '39', enabled),
error: ansi('31', '39', enabled),
// ANSI 36 (cyan) is difficult to read on a light background — use
// ANSI 34 (blue) which is legible on both light and dark schemes.
code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
added: ansi('32', '39', enabled),
removed: ansi('31', '39', enabled),
bold: ansi('1', '22', enabled),
italic: ansi('3', '23', enabled),
underline: ansi('4', '24', enabled),
strike: ansi('9', '29', enabled),
selected: ansi('7', '27', enabled),
}
}
function markdownTheme(palette: Palette): MarkdownTheme {
return {
heading: text => palette.accent(text),
link: text => palette.accent(text),
// pi-tui requires this URL slot but its current Markdown renderer does not invoke it.
/* v8 ignore next */
linkUrl: text => palette.dim(text),
code: text => palette.code(text),
codeBlock: text => palette.text(text),
codeBlockBorder: text => palette.dim(text),
quote: text => palette.muted(text),
quoteBorder: text => palette.accent2(text),
hr: text => palette.dim(text),
listBullet: text => palette.accent(text),
bold: text => palette.bold(text),
italic: text => palette.italic(text),
strikethrough: text => palette.strike(text),
underline: text => palette.underline(text),
}
}
function selectTheme(palette: Palette): SelectListTheme {
return {
selectedPrefix: palette.accent,
selectedText: palette.accent,
description: palette.muted,
scrollInfo: palette.dim,
noMatch: palette.warning,
}
}
function dialogSelectTheme(palette: Palette): SelectListTheme {
return {
...selectTheme(palette),
selectedText: text => palette.selected(palette.accent(text)),
}
}
function contentText(content: readonly ContentBlock[]): string {
const parts: string[] = []
for (const block of content) {
switch (block.type) {
case 'text':
case 'reasoning':
parts.push(block.text)
break
case 'tool-call':
parts.push(`${block.name}(${block.arguments})`)
break
case 'tool-result':
parts.push(contentText(block.content))
break
default: {
const rawType = (block as { type?: unknown }).type
parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`)
break
}
}
}
return parts.join('')
}
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
return content
.filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
.map(block => block.text)
.join('\n\n')
}
interface ModelChoice extends AgentLlmTarget {
modelName: string
description?: string
}
function targetLabel(target: AgentLlmTarget): string {
return `${target.provider}/${target.model}`
}
function initialTarget(agent: Agent): AgentLlmTarget | undefined {
const logged = agent.session.requestHeader()?.config
if (logged !== undefined) return { provider: logged.provider, model: logged.model }
if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
return { provider: agent.options.provider, model: agent.options.model }
}
async function readModelChoices(
ctx: Context,
current: AgentLlmTarget | undefined,
): Promise<ModelChoice[]> {
const providers = ctx.llm.listProviders()
const groups = await Promise.all(providers.map(async (provider) => {
const advertised = await ctx.llm.listModels(provider.id)
const models: LlmModelInfo[] = [...advertised]
if (
current?.provider === provider.id
&& !models.some(model => model.id === current.model)
) {
models.push({ provider: provider.id, id: current.model, name: current.model })
}
return models.map((model): ModelChoice => ({
provider: provider.id,
model: model.id,
modelName: model.name,
...model.description === undefined ? {} : { description: model.description },
}))
}))
return groups.flat()
}
class HeaderComponent implements Component {
constructor(
private readonly agent: Agent,
private readonly subtitle: () => string,
private readonly palette: Palette,
private readonly currentModel: () => string | undefined,
) {}
invalidate(): void {}
render(width: number): string[] {
const usable = Math.max(1, width - 4)
const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}`
const model = displayText(this.currentModel() ?? 'model unset')
const detail = `${model}${displayText(this.agent.session.id)}`
const top = this.palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`)
const bottom = this.palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`)
const lines = [title, this.palette.muted(displayText(this.subtitle())), this.palette.dim(detail)]
.flatMap(line => wrapTextWithAnsi(line, usable))
.map((line) => {
const clipped = truncateToWidth(line, usable, '')
return `${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, usable - visibleWidth(clipped)))} ${this.palette.accent('│')}`
})
return [top, ...lines, bottom]
}
}
/**
* Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so
* it renders legibly on any terminal background — unlike a filled block whose
* body text would collide with the theme's default foreground.
*/
class GutterBox implements Component {
protected readonly children: Component[] = []
constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {}
addChild(child: Component): void {
this.children.push(child)
}
invalidate(): void {
for (const child of this.children) child.invalidate()
}
render(width: number): string[] {
const inner = Math.max(1, width - 2)
const body: string[] = []
for (const child of this.children) for (const line of child.render(inner)) body.push(line)
// Every caller adds a non-empty title/label child, so an all-empty box is unreachable;
// the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars.
/* v8 ignore next */
if (body.length === 0) return []
const bar = this.barFn('▌')
const pad = Array.from({ length: this.paddingY }, () => '')
return [...pad, ...body, ...pad].map(line => `${bar} ${line}`)
}
}
class UserMessageComponent extends GutterBox {
constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
super(value => palette.accent(value))
this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0))
this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, {
preserveOrderedListMarkers: true,
preserveBackslashEscapes: true,
}))
}
}
class AssistantMessageComponent extends Container {
constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) {
super()
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
const text = displayText(textBlocks(content, 'text').trim())
if (reasoning && showReasoning) {
this.addChild(new Spacer(1))
this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0))
this.addChild(new Markdown(reasoning, 1, 0, mdTheme, {
color: value => palette.muted(value),
italic: true,
}))
}
if (text) {
this.addChild(new Spacer(1))
this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0))
this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) }))
}
}
}
interface StreamingBlock {
type: string
text: string
}
class StreamingAssistantComponent extends Container {
private readonly blocks = new Map<number, StreamingBlock>()
constructor(
private showReasoning: boolean,
private readonly palette: Palette,
private readonly mdTheme: MarkdownTheme,
) {
super()
}
update(chunk: StreamChunk): void {
if (chunk.type === 'block-start') {
this.blocks.set(chunk.index, { type: chunk.blockType, text: '' })
} else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
const type = chunk.type === 'text-delta' ? 'text' : 'reasoning'
const block = this.blocks.get(chunk.index) ?? { type, text: '' }
block.text += chunk.text
this.blocks.set(chunk.index, block)
} else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) {
this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text })
}
this.rebuild()
}
setShowReasoning(show: boolean): void {
this.showReasoning = show
this.rebuild()
}
private rebuild(): void {
this.clear()
const content: ContentBlock[] = [...this.blocks.entries()]
.sort(([left], [right]) => left - right)
.flatMap<ContentBlock>(([, block]) => {
if (block.type === 'text') return [{ type: 'text', text: block.text }]
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
return []
})
const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme)
for (const child of component.children) this.addChild(child)
}
}
interface ParsedArguments {
value: unknown
valid: boolean
}
function parseArguments(raw: string): ParsedArguments {
try {
return { value: JSON.parse(raw), valid: true }
} catch {
return { value: raw, valid: false }
}
}
function pretty(value: unknown): string {
if (typeof value === 'string') return displayText(value)
// The lib declaration narrows `unknown` to a string-returning overload, but
// JSON.stringify returns undefined for runtime values such as symbols.
const serialized = JSON.stringify(value, null, 2) as string | undefined
return displayText(serialized ?? String(value))
}
function diffLines(diff: FileDiff, palette: Palette): string[] {
const lines = [palette.bold(displayText(diff.path))]
if (diff.oldText !== null) {
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`))
}
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
return lines
}
class ToolCardComponent implements Component {
private result: { content: ContentBlock[]; isError: boolean; meta?: unknown } | undefined
private expanded = false
private callView: ToolCallView
private resultView: ToolResultView | undefined
constructor(
private readonly name: string,
private readonly parsed: ParsedArguments,
private readonly definition: ToolDefinition | undefined,
private readonly maxOutputLines: number,
private readonly palette: Palette,
) {
this.callView = this.presentCall()
}
private presentCall(): ToolCallView {
if (this.parsed.valid && this.definition?.presentCall) {
try {
const view = this.definition.presentCall(this.parsed.value)
if (view !== undefined) return view
} catch (error: unknown) {
return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` }
}
}
return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value }
}
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
this.result = {
content: [...event.content],
isError: event.isError,
...event.meta !== undefined ? { meta: event.meta } : {},
}
if (this.parsed.valid && this.definition?.presentResult) {
try {
const view = this.definition.presentResult(this.parsed.value, this.result)
if (view !== undefined) this.resultView = view
} catch (error: unknown) {
this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] }
}
}
}
setExpanded(expanded: boolean): void {
this.expanded = expanded
}
invalidate(): void {}
render(width: number): string[] {
const isError = this.result?.isError ?? false
const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓')
const body = this.renderBody()
const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '')
const headLines = Math.ceil(this.maxOutputLines / 2)
const tailLines = this.maxOutputLines - headLines
const visibleBody = this.expanded || body.length <= this.maxOutputLines
? body
: [
...body.slice(0, headLines),
this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
...body.slice(body.length - tailLines),
]
const barFn = this.result === undefined
? this.palette.warning
: isError ? this.palette.error : this.palette.success
const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0)
box.addChild(new Text(this.palette.bold(title), 0, 0))
if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0))
return box.render(width)
}
private title(): string {
return this.resultView?.title ?? this.callView.title
}
private renderBody(): string[] {
const view = this.resultView ?? this.callView
if (view.card === 'terminal') {
const pending = this.callView.card === 'terminal' ? this.callView : undefined
const lines: string[] = []
if (pending?.description) lines.push(this.palette.muted(displayText(pending.description)))
if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd)))
if (this.resultView?.card === 'terminal') {
if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n'))
if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
if (this.resultView.signal !== undefined) {
lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`))
}
} else if (this.result === undefined) {
// A pending terminal view is the call view itself; TerminalCallView requires a title.
lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`))
} else {
lines.push(...displayText(contentText(this.result.content)).split('\n'))
}
return lines.filter(Boolean)
}
if (view.card === 'diff') {
return view.diffs.flatMap((diff, index) => [
...index > 0 ? [''] : [],
...diffLines(diff, this.palette),
])
}
const content = view.content ?? this.result?.content
const lines: string[] = []
if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n'))
const rawInput = this.result === undefined && this.callView.card === 'generic'
? this.callView.rawInput
: undefined
if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n'))
return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1))
}
}
class TodoComponent implements Component {
private todos: readonly TodoItem[] = []
constructor(private readonly palette: Palette) {}
update(todos: readonly TodoItem[]): void {
this.todos = todos
}
invalidate(): void {}
render(width: number): string[] {
if (this.todos.length === 0) return []
const lines = [this.palette.bold(this.palette.accent('Plan'))]
for (const todo of this.todos) {
const prefix = todo.status === 'completed'
? this.palette.success('✓')
: todo.status === 'in_progress'
? this.palette.warning('●')
: this.palette.dim('○')
const content = displayText(todo.content)
const text = todo.status === 'completed' ? this.palette.muted(content) : content
lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
}
return ['', ...lines]
}
}
function formatTokens(value: number): string {
if (value < 1_000) return String(value)
if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`
if (value < 1_000_000) return `${Math.round(value / 1_000)}k`
return `${(value / 1_000_000).toFixed(1)}m`
}
function formatCwd(cwd: string | undefined): string {
if (cwd === undefined) return 'cwd unset'
const home = homedir()
const rel = relative(resolve(home), resolve(cwd))
if (rel === '') return '~'
/* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
if (isAbsolute(rel)) return cwd
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
return cwd
}
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
}
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 {
constructor(
private readonly agent: Agent,
private readonly palette: Palette,
private readonly toolsExpanded: () => boolean,
private readonly showReasoning: () => boolean,
private readonly tokens: () => { input: number; output: number },
private readonly cwdFormatter: TuiRuntime['formatCwd'],
private readonly currentModel: () => string | undefined,
private readonly contextPercent: () => number | undefined,
private readonly runningSeconds: () => number,
) {}
invalidate(): void {}
render(width: number): string[] {
if (this.agent.status === 'running') {
const interrupt = this.palette.dim('esc interrupt')
const activityAvailable = Math.max(0, width - visibleWidth(interrupt) - 1)
const activity = truncateToWidth(this.palette.accent(`◒ Working · ${this.runningSeconds()}s`), activityAvailable, '')
const gap = ' '.repeat(Math.max(0, width - visibleWidth(activity) - visibleWidth(interrupt)))
return [`${activity}${gap}${interrupt}`]
}
const { input, output } = this.tokens()
const counters = `${formatTokens(input)}${formatTokens(output)}`
const model = displayText(this.currentModel() ?? 'model unset')
const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})`
const contextPercent = this.contextPercent()
const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context`
const fullRight = `${context} tools:${this.toolsExpanded() ? 'expanded' : 'compact'} ${modelState}`
const compactRight = `${context} ${modelState}`
const formattedCwd = displayText(
this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd),
)
if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
const compact = truncateToWidth(compactRight, width, '')
return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`]
}
const rightAvailable = width - visibleWidth(counters) - 1
const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight
const rightClipped = truncateToWidth(right, rightAvailable, '')
const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3)
const cwd = truncateToWidth(formattedCwd, cwdAvailable, '')
const left = [cwd, counters].filter(Boolean).join(' ')
const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped)))
return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`]
}
}
interface QuestionSelection {
selected: string[]
custom?: string
}
function renderDialog(
title: string,
body: readonly string[],
width: number,
palette: Palette,
): string[] {
const innerWidth = Math.max(1, width - 4)
const topLabel = ` ${displayText(title)} `
const top = `${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}`
const lines: string[] = [palette.accent(top)]
for (const line of body) {
const clipped = truncateToWidth(line, innerWidth, '')
lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`)
}
lines.push(palette.accent(`${'─'.repeat(Math.max(0, width - 2))}`))
return lines
}
class ModelDialog implements Component {
private readonly list: SelectList
constructor(
choices: readonly ModelChoice[],
current: AgentLlmTarget | undefined,
maxVisible: number,
private readonly palette: Palette,
done: (choice: ModelChoice) => void,
cancel: () => void,
) {
this.list = new SelectList(choices.map(choice => ({
value: targetLabel(choice),
label: displayText(targetLabel(choice)),
description: [
displayText(choice.modelName),
...choice.description === undefined ? [] : [displayText(choice.description)],
...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [],
].join(' — '),
})), maxVisible, dialogSelectTheme(palette))
const currentIndex = current === undefined
? 0
: choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
this.list.setSelectedIndex(currentIndex)
this.list.onSelect = (item) => {
const selected = choices.find(choice => targetLabel(choice) === item.value)
/* v8 ignore next -- SelectList only returns values built from `choices`. */
if (selected === undefined) return
done(selected)
}
this.list.onCancel = cancel
}
invalidate(): void {
this.list.invalidate()
}
handleInput(data: string): void {
this.list.handleInput(data)
this.invalidate()
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - 4)
return renderDialog('Select model', [
...this.list.render(innerWidth),
'',
this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'),
], width, this.palette)
}
}
class QuestionDialog implements Component, Focusable {
private selectedIndex = 0
private selected = new Set<number>()
private mode: 'options' | 'custom'
private error = ''
private readonly input = new Input()
private readonly options: NonNullable<AskUserQuestionItem['options']>
focused = false
constructor(
private readonly question: AskUserQuestionItem,
private readonly position: number,
private readonly total: number,
private readonly unanswered: number,
private readonly maxVisible: number,
private readonly palette: Palette,
private readonly done: (selection: QuestionSelection) => void,
private readonly cancel: () => void,
) {
this.options = question.options ?? []
this.mode = this.options.length > 0 ? 'options' : 'custom'
this.input.onSubmit = (value) => { this.submitCustom(value) }
this.input.onEscape = () => {
if (this.options.length > 0) {
this.mode = 'options'
this.error = ''
} else {
this.cancel()
}
}
}
invalidate(): void {
this.input.invalidate()
}
handleInput(data: string): void {
this.invalidate()
if (this.mode === 'custom') {
this.input.focused = this.focused
this.input.handleInput(data)
return
}
const options = this.options
if (matchesKey(data, Key.up)) {
this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1
} else if (matchesKey(data, Key.space) && this.question.multiSelect) {
if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
else this.selected.add(this.selectedIndex)
} else if (matchesKey(data, Key.enter)) {
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
if (indices.length === 0) {
this.error = 'Select at least one option, or press Tab for a custom answer.'
return
}
this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
this.mode = 'custom'
this.error = ''
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
this.cancel()
}
}
private submitCustom(value: string): void {
const custom = value.trim()
if (custom === '') {
this.error = 'Enter an answer before submitting.'
return
}
this.done({ selected: [], custom })
}
render(width: number): string[] {
this.input.focused = this.focused
const innerWidth = Math.max(1, width - 4)
const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
const lines = [
this.palette.muted(header),
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
'',
]
const push = (line: string): void => { lines.push(line) }
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) push(line)
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
} else {
const options = this.options
const start = Math.max(0, Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
options.length - this.maxVisible,
))
const end = Math.min(options.length, start + this.maxVisible)
const optionRows = options.slice(start, end).map((option, offset) => {
const index = start + offset
const mark = this.question.multiSelect
? this.selected.has(index) ? '[x] ' : '[ ] '
: ''
return `${index === this.selectedIndex ? '' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
})
const descriptionColumn = Math.min(
Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
Math.max(1, Math.floor(innerWidth * 0.55)),
)
for (let index = start; index < end; index += 1) {
// `index < end <= options.length`; the options array is borrowed immutably for this dialog.
const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
const mark = this.question.multiSelect
? this.selected.has(index) ? '[x] ' : '[ ] '
: ''
const left = `${index === this.selectedIndex ? '' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
const leftStyled = index === this.selectedIndex
? this.palette.bold(this.palette.accent(left))
: left
const description = option.description === undefined
? ''
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
push(`${leftStyled}${description}`)
}
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
const hint = this.palette.dim(this.question.multiSelect
? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt'
: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt')
for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
}
if (this.error) {
for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
}
return ['', ...lines, ''].map((line) => {
const clipped = truncateToWidth(line, innerWidth, '')
return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `
})
}
}
interface PendingQuestion {
request: AskUserQuestionRequest
index: number
answers: AskUserQuestionAnswerItem[]
resolve(answer: AskUserQuestionAnswer): void
reject(error: unknown): void
onAbort: () => void
overlay: OverlayHandle | undefined
}
/** Lifecycle handle for a mounted interactive terminal channel. */
export interface TuiController {
/** Stop rendering, restore the terminal, and reject pending questions. */
dispose(): Promise<void>
}
function activeSurfaceSeqs(session: Session): Set<number> {
return new Set(session.surface.nodes)
}
function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
const ids = new Set<string>()
for (const event of session.events) {
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
for (const block of event.data.content) {
if (block.type === 'tool-call') ids.add(block.id)
}
}
return ids
}
/**
* Start the interactive pi-tui channel for an already-created target agent.
* @param ctx - agent, tools, session-event, and user-interaction context.
* @param config - target agent, banner, and TUI presentation config.
* @param runtime - terminal and process-exit boundary.
* @returns lifecycle controller used by the Cordis effect disposer.
*/
export function createTuiChat(
ctx: Context,
config: Config,
runtime: TuiRuntime,
): TuiController {
const sessionId = SessionId(config.sessionId ?? 'main')
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
const resolved = resolveTuiConfig(config)
const palette = createPalette(resolved.color)
const mdTheme = markdownTheme(palette)
const ui = new TUI(runtime.terminal, resolved.showHardwareCursor)
const chat = new Container()
const todoContainer = new Container()
const editor = new Editor(ui, {
borderColor: palette.dim,
selectList: selectTheme(palette),
} satisfies EditorTheme, { paddingX: 1 })
const todo = new TodoComponent(palette)
let showReasoning = resolved.showReasoning
let toolsExpanded = false
let streaming: StreamingAssistantComponent | undefined
let runningStartedAt: number | undefined
let statusTicker: ReturnType<typeof setInterval> | undefined
let disposed = false
let shuttingDown: Promise<void> | undefined
const tokens = sessionTokens(agent.session)
const toolCards = new Map<string, ToolCardComponent>()
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 }
let contextWindow: number | undefined
let contextResolution: Promise<
| { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
| { readonly kind: 'error'; readonly error: unknown }
> | undefined
let modelCommands = Promise.resolve()
const now = (): number => runtime.now?.() ?? Date.now()
const welcome = config.welcome ?? 'ready.'
let sessionTitle = foldSessionTitle(agent.session.events)?.title
const header = new HeaderComponent(agent, () => sessionTitle ?? welcome, palette, () => target.current?.model)
const footer = new FooterComponent(
agent,
palette,
() => toolsExpanded,
() => showReasoning,
() => tokens,
runtime.formatCwd,
() => target.current?.model,
() => contextWindow === undefined
? undefined
: Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
() => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)),
)
ui.addChild(header)
ui.addChild(chat)
todoContainer.addChild(todo)
ui.addChild(todoContainer)
ui.addChild(editor)
ui.addChild(footer)
ui.setFocus(editor)
const updateTerminalTitle = (): void => {
runtime.terminal.setTitle(displayText(
sessionTitle === undefined ? resolved.title : `${sessionTitle}${resolved.title}`,
))
}
updateTerminalTitle()
const requestRender = (): void => {
footer.invalidate()
ui.requestRender()
}
const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => {
const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted
chat.addChild(new Spacer(1))
chat.addChild(new Text(color(displayText(message)), 1, 0))
requestRender()
}
const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
contextWindow = undefined
const resolution = selected === undefined
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
: ctx.llm.resolveModelContext(selected.provider, selected.model).then(
context => ({ kind: 'resolved', contextWindow: context?.contextWindow } as const),
(error: unknown) => ({ kind: 'error', error } as const),
)
contextResolution = resolution
void resolution.then((result) => {
if (contextResolution !== resolution) return
if (result.kind === 'error') {
appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
return
}
contextWindow = result.contextWindow
requestRender()
})
}
resolveContextWindow(target.current)
const selectModel = (selected: ModelChoice): void => {
if (target.current?.provider === selected.provider && target.current.model === selected.model) {
appendNotice(`Model is already ${targetLabel(selected)}.`)
return
}
target.current = { provider: selected.provider, model: selected.model }
resolveContextWindow(target.current)
appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
}
const showModelSelector = (choices: readonly ModelChoice[]): void => {
const current = target.current === undefined ? 'unset' : targetLabel(target.current)
if (choices.length === 0) {
appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
return
}
modelOverlay?.hide()
modelOverlay = undefined
const close = (): void => {
modelOverlay?.hide()
modelOverlay = undefined
requestRender()
}
const dialog = new ModelDialog(
choices,
target.current,
resolved.maxModelOptions,
palette,
(selected) => {
close()
selectModel(selected)
},
close,
)
modelOverlay = ui.showOverlay(dialog, {
width: resolved.modelDialogWidth,
maxHeight: resolved.modelDialogMaxHeight,
anchor: 'center',
margin: 1,
})
requestRender()
}
const handleModelCommand = async (raw: string): Promise<void> => {
const choices = await readModelChoices(ctx, target.current)
if (disposed) return
const argument = raw.trim()
if (argument === '') {
showModelSelector(choices)
return
}
const parts = argument.split(/\s+/u)
if (parts.length > 2) {
appendNotice('Usage: /model [provider/]model', 'warning')
return
}
let matches: ModelChoice[]
if (parts.length === 2) {
matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
} else {
const value = argument
const qualified = choices.filter(choice => targetLabel(choice) === value)
matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
}
if (matches.length === 0) {
appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
return
}
if (matches.length > 1) {
appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
return
}
const selected = matches[0]
/* v8 ignore next -- a non-empty matches array always has index zero. */
if (selected === undefined) return
selectModel(selected)
}
const queueModelCommand = (raw: string): void => {
modelCommands = modelCommands.then(async () => {
await handleModelCommand(raw)
}).catch((error: unknown) => {
if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
})
}
const clearStatus = (): void => {
if (statusTicker !== undefined) clearInterval(statusTicker)
statusTicker = undefined
runningStartedAt = undefined
runtime.terminal.setProgress(false)
}
const setStatus = (status: AgentStatus): void => {
clearStatus()
editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
if (status === 'running') {
runningStartedAt = now()
statusTicker = setInterval(requestRender, 1_000)
statusTicker.unref()
runtime.terminal.setProgress(true)
}
requestRender()
}
const parsedTool = (event: Extract<SessionEvent, { type: 'tool/call' }>): ToolCardComponent => {
const parsed = parseArguments(event.data.arguments)
const card = new ToolCardComponent(
event.data.name,
parsed,
ctx.tools.get(event.data.name, agent),
resolved.maxToolOutputLines,
palette,
)
card.setExpanded(toolsExpanded)
toolCards.set(event.data.callId, card)
allToolCards.add(card)
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': {
const text = displayText(contentText(event.data.content).trim())
if (text) {
chat.addChild(new Spacer(1))
chat.addChild(new UserMessageComponent(text, palette, mdTheme))
if (options.addHistory) editor.addToHistory(text)
}
break
}
case 'steering/message': {
const text = displayText(contentText(event.data.content).trim())
if (text) {
chat.addChild(new Spacer(1))
chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
}
break
}
case 'context/message': {
const text = displayText(contentText(event.data.content).trim())
if (text) {
const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0))
chat.addChild(new Text(palette.muted(text), 1, 0))
}
break
}
case 'prompt/blocked':
appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
break
case 'assistant/chunk':
if (options.renderChunks) {
if (streaming === undefined) {
streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme)
chat.addChild(streaming)
}
streaming.update(event.data.chunk)
}
break
case 'assistant/message': {
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))
break
case 'tool/result': {
let card = toolCards.get(event.data.callId)
if (card === undefined) {
card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette)
chat.addChild(new Spacer(1))
chat.addChild(card)
allToolCards.add(card)
}
card.updateResult(event.data)
toolCards.delete(event.data.callId)
break
}
case 'todo/write':
todo.update(event.data.todos)
break
case 'session/title':
sessionTitle = event.data.title
header.invalidate()
updateTerminalTitle()
break
case 'turn/end':
clearStreaming()
if (event.data.reason.kind === 'error') {
const key = `${event.data.turn}:${event.data.reason.step}`
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('Turn cancelled.', 'warning')
} else if (event.data.reason.kind === 'max-tokens') {
appendNotice('The model reached its output-token limit.', 'warning')
} else if (event.data.reason.kind === 'rejected') {
appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning')
} else if (event.data.reason.kind === 'interrupted') {
appendNotice('The previous process ended during this turn.', 'warning')
}
break
default:
break
}
}
const rebuildTranscript = (populateHistory: boolean): void => {
chat.clear()
toolCards.clear()
allToolCards.clear()
streaming = undefined
const active = activeSurfaceSeqs(agent.session)
const activeCalls = activeToolCallIds(agent.session, active)
for (const event of agent.session.events) {
const isSurface = event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'context/message'
|| event.type === 'steering/message'
if (isSurface && !active.has(event.seq)) continue
if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
renderEvent(event, { addHistory: populateHistory, renderChunks: false })
}
requestRender()
}
const removeAbortListener = (pending: PendingQuestion): void => {
pending.request.signal?.removeEventListener('abort', pending.onAbort)
}
const rejectQuestion = (pending: PendingQuestion): void => {
pending.overlay?.hide()
pending.overlay = undefined
removeAbortListener(pending)
pending.reject(new UserInteractionError(
'ask_user_question was interrupted before the user answered',
'ASK_ABORTED',
))
}
const startNextQuestion = (): void => {
if (activeQuestion !== undefined || disposed) return
const pending = questionQueue.shift()
if (pending === undefined) return
activeQuestion = pending
const show = (): void => {
const question = pending.request.questions[pending.index]
if (question === undefined) {
activeQuestion = undefined
removeAbortListener(pending)
pending.resolve({ answers: pending.answers })
startNextQuestion()
return
}
const dialog = new QuestionDialog(
question,
pending.index + 1,
pending.request.questions.length,
pending.request.questions.length - pending.answers.length,
resolved.maxQuestionOptions,
palette,
(selection) => {
pending.overlay?.hide()
pending.overlay = undefined
pending.answers.push({ id: question.id, ...selection })
pending.index += 1
show()
},
() => {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
},
)
pending.overlay = ui.showOverlay(dialog, {
width: resolved.questionDialogWidth,
maxHeight: resolved.questionDialogMaxHeight,
anchor: 'bottom-left',
margin: { bottom: 1 },
})
requestRender()
}
show()
}
const disposeUserInteraction = ctx.userInteraction.registerProvider({
ask(request) {
return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
const pending: PendingQuestion = {
request,
index: 0,
answers: [],
resolve: resolveAnswer,
reject,
overlay: undefined,
onAbort: () => {
if (activeQuestion === pending) {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
return
}
// A non-active pending ask remains in the queue until this listener settles it.
questionQueue.splice(questionQueue.indexOf(pending), 1)
rejectQuestion(pending)
},
}
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
questionQueue.push(pending)
startNextQuestion()
})
},
})
const shutdown = (exitProcess: boolean): Promise<void> => {
shuttingDown ??= (async () => {
disposed = true
contextResolution = undefined
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
rejectQuestion(pending)
}
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
disposeUserInteraction()
await runtime.terminal.drainInput(100, 20)
ui.stop()
if (exitProcess) runtime.exit(0)
})()
return shuttingDown
}
const requestExit = (): void => {
if (agent.status === 'running') {
agent.cancel({ kind: 'user' })
appendNotice('Cancelling the active turn before exit…', 'warning')
void agent.whenIdle().then(() => shutdown(true))
return
}
void shutdown(true)
}
/** Swap the palette and all derived themes for the given terminal color scheme. */
const applyColorScheme = (scheme: TerminalColorScheme): void => {
if (scheme === currentScheme) return
currentScheme = scheme
Object.assign(palette, createPalette(resolved.color, scheme))
Object.assign(mdTheme, markdownTheme(palette))
rebuildTranscript(false)
setStatus(agent.status)
requestRender()
}
let currentScheme: TerminalColorScheme = 'dark'
// Apply any color scheme the terminal reports. Registering before the query
// below means even a synchronous reply reaches `applyColorScheme`; in practice
// the startup query's reply is the only report, since dsh-tui leaves
// unsolicited color-scheme notifications disabled.
const disposeSchemeListener = ui.onTerminalColorSchemeChange(applyColorScheme)
// Ask the terminal for its color scheme via device-status report; the reply,
// if any, arrives through the listener above. Most terminals do not respond,
// so we keep the dark-optimised palette. Swallow a query-write failure for the
// same reason.
ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})
const toggleTools = (): void => {
toolsExpanded = !toolsExpanded
for (const card of allToolCards) card.setExpanded(toolsExpanded)
appendNotice(`Tool cards ${toolsExpanded ? 'expanded' : 'collapsed'}.`)
}
const toggleReasoning = (): void => {
showReasoning = !showReasoning
const activeStreaming = streaming
rebuildTranscript(false)
if (activeStreaming !== undefined) {
streaming = activeStreaming
streaming.setShowReasoning(showReasoning)
chat.addChild(activeStreaming)
}
appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`)
}
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',
'',
...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({ kind: 'user' })
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 (value.startsWith('/')) {
runCommand(value)
return
}
if (agent.status === 'disposed') {
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
} else if (agent.status === 'running') {
agent.steer([{ type: 'text', text }])
} else {
agent.send([{ type: 'text', text }])
}
}
const removeInputListener = ui.addInputListener((data) => {
if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined
if (matchesKey(data, Key.ctrl('o'))) {
toggleTools()
return { consume: true }
}
if (matchesKey(data, Key.ctrl('r'))) {
toggleReasoning()
return { consume: true }
}
if (matchesKey(data, Key.ctrl('l'))) {
ui.invalidate()
ui.requestRender(true)
return { consume: true }
}
if (matchesKey(data, Key.escape) && agent.status === 'running') {
agent.cancel({ kind: 'user' })
return { consume: true }
}
if (matchesKey(data, Key.ctrl('c'))) {
if (agent.status === 'running') {
agent.cancel({ kind: 'user' })
} else if (editor.getText() !== '') {
editor.setText('')
} else {
requestExit()
}
return { consume: true }
}
if (matchesKey(data, Key.ctrl('d'))) {
if (agent.status === 'running') appendNotice('Cancel the active turn before exiting.', 'warning')
else requestExit()
return { consume: true }
}
return undefined
})
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
recordEventUsage(tokens, event)
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
rebuildTranscript(false)
return
}
renderEvent(event, { addHistory: false, renderChunks: true })
requestRender()
})
const disposeStatus = ctx.on('agent/status', (subject, status) => {
if (subject !== agent) return
setStatus(status)
})
const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
if (subject !== agent) return
liveErrors.add(`${turn}:${step}`)
// Full cause chain: wrapper messages like `fetch failed` carry the
// actionable transport detail on `cause`.
appendNotice(errorChain(error), 'error')
})
const disposeAgent = ctx.on('agent/disposed', (subject) => {
if (subject !== agent) return
clearStatus()
appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
})
const detachListeners = (): void => {
removeInputListener()
disposeCommandChanges()
disposeSessionEvents()
disposeStatus()
disposeError()
disposeAgent()
disposeSchemeListener()
disposeTargetListeners()
}
rebuildTranscript(true)
setStatus(agent.status)
try {
ui.start()
} 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()
throw error
}
return {
async dispose(): Promise<void> {
detachListeners()
await shutdown(false)
await commandFiber.dispose()
},
}
}
/**
* Open the pi-tui channel once its configured agent exists.
*
* @param ctx - Context supplying the agent registry, tools, and event stream.
* @param config - Target agent and presentation configuration.
* @param runtime - Terminal and process-exit boundary.
*/
export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void {
const sessionId = SessionId(config.sessionId ?? 'main')
const matchesConfiguredIdentity = (agent: Agent): boolean =>
agent.id === sessionId && ctx.agents.roots().includes(agent)
let settled = false
const stopWaiting = (): void => {
disposeCreated()
disposeFailure()
}
const start = (agent: Agent): void => {
if (settled || !matchesConfiguredIdentity(agent)) return
settled = true
stopWaiting()
ctx.effect(() => {
const controller = createTuiChat(ctx, config, runtime)
return () => controller.dispose()
}, 'ui-tui')
}
const fail = (failedSessionId: SessionId, error: unknown): void => {
if (settled || failedSessionId !== sessionId) return
settled = true
stopWaiting()
runtime.terminal.write(displayText(`ui-tui: session "${sessionId}" failed to start: ${errorChain(error)}\n`))
runtime.exit(1)
}
const disposeCreated = ctx.on('agent/created', start)
const disposeFailure = ctx.on('agent-loop/config-start-failed', fail)
const existing = ctx.agents.roots().find(agent => agent.id === sessionId)
if (existing !== undefined) start(existing)
}
/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
and the tui-agent PTY smoke covers the real entry */
export function apply(ctx: Context, config: Config): void {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-cli-demo for non-interactive runs')
}
mountTui(ctx, config, {
terminal: new ProcessTerminal(),
exit: code => process.exit(code),
})
}
/* v8 ignore stop */