/** * 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 = 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 = 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 => 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 { 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() 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(([, 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['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 } 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() private mode: 'options' | 'custom' private error = '' private readonly input = new Input() private readonly options: NonNullable 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[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 } function activeSurfaceSeqs(session: Session): Set { return new Set(session.surface.nodes) } function activeToolCallIds(session: Session, active: ReadonlySet): Set { const ids = new Set() 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 | undefined let disposed = false let shuttingDown: Promise | undefined const tokens = sessionTokens(agent.session) const toolCards = new Map() const allToolCards = new Set() const liveErrors = new Set() const questionQueue: PendingQuestion[] = [] const commandControllers = new Set() 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 => { 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 /.`, '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): 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((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 => { 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 { 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 */