/** * 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 { relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, Editor, Input, Key, Loader, Markdown, Spacer, Text, TUI, ProcessTerminal, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type EditorTheme, type Focusable, type MarkdownTheme, type OverlayHandle, type SelectListTheme, type Terminal, } from '@earendil-works/pi-tui' import type { Context } from 'cordis' import z from 'schemastery' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-loop' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session' 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', 'userInteraction', 'tools'] /** Presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ showReasoning?: boolean /** Maximum tool-output lines shown before the card is collapsed. */ maxToolOutputLines?: number /** Maximum options visible at once in a user-question dialog. */ maxQuestionOptions?: number /** User-question dialog width in terminal columns. */ questionDialogWidth?: number /** User-question dialog maximum height in terminal rows. */ questionDialogMaxHeight?: 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(12) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(72) const questionDialogMaxHeightSchema = 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, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, 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, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, showHardwareCursor: showHardwareCursorSchema, color: colorSchema, title: titleSchema, }) /** Fully defaulted TUI presentation settings. */ export interface ResolvedTuiConfig { showReasoning: boolean maxToolOutputLines: number maxQuestionOptions: number questionDialogWidth: number questionDialogMaxHeight: 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 } /** * 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 ?? 12, maxQuestionOptions: config?.maxQuestionOptions ?? 8, questionDialogWidth: config?.questionDialogWidth ?? 72, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 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')}`) } /** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */ function renderThrown(value: unknown): string { try { return String(value) } catch { return '' } } /** * 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): Palette { return { accent: ansi('94', '39', enabled), accent2: ansi('95', '39', enabled), text: text => text, muted: ansi('90', '39', enabled), dim: ansi('2', '22', enabled), success: ansi('32', '39', enabled), warning: ansi('33', '39', enabled), error: ansi('31', '39', enabled), code: 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 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') } class HeaderComponent implements Component { constructor( private readonly agent: Agent, private readonly welcome: string, private readonly palette: Palette, ) {} 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.agent.options.model ?? '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.welcome)), 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 visibleBody = this.expanded || body.length <= this.maxOutputLines ? body : [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)] 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 '~' if (rel !== '..' && !rel.startsWith(`..${sep}`)) return displayText(`~${sep}${rel}`) return displayText(cwd) } function sessionTokens(session: Session): { input: number; output: number } { let input = 0 let output = 0 for (const event of session.events) { if (event.type !== 'assistant/message' || event.data.usage === undefined) continue input += event.data.usage.inputTokens output += event.data.usage.outputTokens } return { input, output } } 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 }, ) {} invalidate(): void {} render(width: number): string[] { const { input, output } = this.tokens() const left = `${formatCwd(this.agent.session.header.cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}` const leftStyled = this.palette.dim(left) const available = Math.max(0, width - visibleWidth(left) - 2) const rightClipped = truncateToWidth(right, available, '') const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] } } interface QuestionSelection { selected: string[] custom?: string } 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 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 C for a custom answer.' return } this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) } else if (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 title = displayText(this.question.header ?? 'Question') const topLabel = ` ${title} ` const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` const lines: string[] = [this.palette.accent(top)] const push = (line: string): void => { const clipped = truncateToWidth(line, innerWidth, '') lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`) } for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line) push('') 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) 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 cursor = index === this.selectedIndex ? this.palette.accent('›') : ' ' const mark = this.question.multiSelect ? this.selected.has(index) ? this.palette.success('[x]') : '[ ]' : index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○') const description = option.description ? this.palette.muted(` — ${displayText(option.description)}`) : '' const line = `${cursor} ${mark} ${displayText(option.label)}${description}` push(index === this.selectedIndex ? this.palette.selected(line) : line) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) push(this.palette.dim(this.question.multiSelect ? '↑↓ navigate • Space toggle • Enter submit • C custom • Esc cancel' : '↑↓ navigate • Enter select • C custom • Esc cancel')) } if (this.error) push(this.palette.error(this.error)) lines.push(this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) return lines } } 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 statusContainer = 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 statusLoader: Loader | 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[] = [] let activeQuestion: PendingQuestion | undefined const welcome = config.welcome ?? 'ready.' const header = new HeaderComponent(agent, welcome, palette) const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens) ui.addChild(header) ui.addChild(chat) ui.addChild(statusContainer) todoContainer.addChild(todo) ui.addChild(todoContainer) ui.addChild(editor) ui.addChild(footer) ui.setFocus(editor) runtime.terminal.setTitle(displayText(resolved.title)) 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 clearStatus = (): void => { statusLoader?.stop() statusLoader = undefined statusContainer.clear() 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') { statusLoader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), 'Working — Enter sends steering, Esc cancels') statusContainer.addChild(statusLoader) 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 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': { if (streaming !== undefined) { const index = chat.children.indexOf(streaming) if (index >= 0) chat.children.splice(index, 1) streaming = undefined } const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme) if (component.children.length > 0) chat.addChild(component) 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 'turn/end': if (event.data.reason.kind === 'error') { const key = `${event.data.turn}:${event.data.reason.step}` if (!liveErrors.delete(key)) appendNotice(event.data.reason.message, 'error') } else if (event.data.reason.kind === 'aborted') { appendNotice(event.data.reason.reason ?? '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, 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: 'center', margin: 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 clearStatus() 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('terminal exit requested') appendNotice('Cancelling the active turn before exit…', 'warning') void agent.whenIdle().then(() => shutdown(true)) return } void shutdown(true) } editor.setAutocompleteProvider(new CombinedAutocompleteProvider([ { name: 'help', description: 'Show keyboard shortcuts and commands' }, { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' }, { name: 'cancel', description: 'Cancel the active turn' }, { name: 'reasoning', description: 'Toggle reasoning blocks' }, { name: 'tools', description: 'Expand or collapse all tool cards' }, { name: 'redraw', description: 'Invalidate components and redraw the terminal' }, { name: 'exit', description: 'Exit after the active turn reaches idle' }, ], agent.session.header.cwd ?? process.cwd())) const toggleTools = (): void => { toolsExpanded = !toolsExpanded for (const card of allToolCards) card.setExpanded(toolsExpanded) 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 => { chat.addChild(new Spacer(1)) chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) chat.addChild(new Text([ 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', 'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning', 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', '/help /clear /cancel /reasoning /tools /redraw /exit', ].map(line => palette.muted(line)).join('\n'), 1, 0)) requestRender() } editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return editor.addToHistory(text) editor.setText('') switch (text) { case '/help': showHelp() return case '/clear': chat.clear() requestRender() return case '/cancel': if (agent.status === 'running') agent.cancel('cancelled from terminal') else appendNotice('The agent is already idle.') return case '/reasoning': toggleReasoning() return case '/tools': toggleTools() return case '/redraw': ui.invalidate() ui.requestRender(true) return case '/exit': requestExit() return default: if (text.startsWith('/')) { appendNotice(`Unknown command: ${text}`, 'warning') return } } if (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) 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('cancelled from terminal') return { consume: true } } if (matchesKey(data, Key.ctrl('c'))) { if (agent.status === 'running') { agent.cancel('cancelled from terminal') } 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 if (event.type === 'assistant/message' && event.data.usage !== undefined) { tokens.input += event.data.usage.inputTokens tokens.output += event.data.usage.outputTokens } 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}`) appendNotice(error.message, 'error') }) const disposeAgent = ctx.on('agent/disposed', (subject) => { if (subject !== agent) return clearStatus() appendNotice(`Agent "${agent.id}" was disposed.`, 'warning') }) const detachListeners = (): void => { removeInputListener() disposeSessionEvents() disposeStatus() disposeError() disposeAgent() } rebuildTranscript(true) setStatus(agent.status) try { ui.start() } catch (error: unknown) { disposed = true detachListeners() clearStatus() disposeUserInteraction() ui.stop() throw error } return { async dispose(): Promise { detachListeners() await shutdown(false) }, } } /** * 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: ${renderThrown(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 repl-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-stdio for pipes') } mountTui(ctx, config, { terminal: new ProcessTerminal(), exit: code => process.exit(code), }) } /* v8 ignore stop */