diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml new file mode 100644 index 0000000000..a22f2b68fa --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md +2026-07-27-tui-chat-channel-module-split.md: 56b345b670cd8426780bfdd8c2b2f5719461554c +2026-07-27-tui-chat-channel-module-split.zh.md: d74844a762bc519d0f499696fe343567eebfe920 diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md new file mode 100644 index 0000000000..56b345b670 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.md @@ -0,0 +1,36 @@ +# Agent Note: dsh-tui chat channel module split + +Status: implemented + +English | [中文](2026-07-27-tui-chat-channel-module-split.zh.md) + +## Problem + +`packages/ui/tui/src/index.ts` had grown past 2000 lines. Most of it was one `createTuiChat` factory: a ~1600-line closure holding roughly forty mutable variables and as many nested closures. Model selection, the ask-user-question queue, and session resume were tangled into that single scope, so a reader could not follow any one concern without holding the whole file in their head, and unrelated edits collided. A prior pass had grouped `src/` into `components/`, `session/`, `extension/`, but the entry file itself and the loose top-level input files (`autocomplete.ts`, `file-autocomplete.ts`, `skill-invocation.ts`, `xml-tool-output.ts`) were untouched. + +## Decision + +The chat channel's cohesive sub-machines are extracted from `createTuiChat` into `src/chat/`, each a factory that takes an explicit dependency bundle instead of closing over the entry scope: + +- `chat/model-command.ts` — `createModelController`: the queued `/model` command, the model+reasoning-effort selector overlay, and the selected model's context-window resolution. Owns the context-window cache that the prompt and status views read. +- `chat/questions.ts` — `createQuestionQueue`: the user-interaction provider and the one-at-a-time FIFO ask-user-question overlays. +- `chat/resume.ts` — `createResumeController`: the `/resume` selector, per-candidate summary reads, the pre-handoff preflight, the terminal handoff, and the durable resume-hint command. +- `chat/helpers.ts` — zero-state helpers (`formatCwd`, `gitBranch`, surface/tool-call derivations, session-reference cards), the `HintEditor`, and banner-reveal constants. +- `chat/channel.ts` — `ChatChannelDeps` (the collaborator surface every sub-controller shares) and `ChannelNotice` (mixed in by the controllers that report outcomes). Each `*Deps` extends these, so the shared surface has one definition. + +`src/` is reorganized so `chat/` holds every chat-channel concern: the sub-controllers above plus the former input files and the former `session/` files (`timing.ts`, `tokens.ts`) all move under `chat/`. `xml-tool-output.ts` moves under `components/`. The host/process boundary interfaces (`TuiRuntime`, `TuiResumeHost`) move to `src/runtime.ts`. After the split `src/` is `chat/`, `components/`, `extension/`, and the top-level `index.ts` / `config.ts` / `prompt.ts` / `runtime.ts` / `invariant.ts`; `index.ts` drops from 2067 to ~1530 lines and now constructs and wires the three controllers. + +The convention for a controller's dependency bundle: stable value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`, and each controller's own services) are destructured once; the channel callbacks (`appendNotice`, `requestRender`, `isDisposed`, `agentStatus`) stay on `deps` so a controller always calls the channel's current implementation. `channel.ts`'s JSDoc states this rule. + +## Alternatives considered + +- **Free functions taking a shared mutable context object.** Rejected: it would re-expose the same forty-field grab-bag the split set out to remove, just under a parameter name. +- **Extracting the status/timing animation controller too.** Deferred: `runningStatus` is read directly by the prompt caret animation in `updatePromptValues`, so a controller boundary there would leak its internal state back through getters — a leaky seam for little gain. It stays inline in `index.ts`. + +## Consequences + +Each concern is now readable and testable in isolation, and the shared dependency surface is defined once instead of copied into three interfaces. The cost: `index.ts` constructs the controllers and threads the callback bundle, and the model controller is a `let` forward-reference (`updatePromptValues` closes over it, but it is built later once `appendNotice`/`overlayManager` exist), carrying one justified `prefer-const` disable and a deferred first paint. + +## Testing + +Behavior is unchanged: all existing package tests and TUI snapshots pass without re-recording, which is the contract for this refactor. diff --git a/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md new file mode 100644 index 0000000000..d74844a762 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-27-tui-chat-channel-module-split.zh.md @@ -0,0 +1,36 @@ +# Agent Note: dsh-tui 聊天通道模块拆分 + +Status: implemented + +[English](2026-07-27-tui-chat-channel-module-split.md) | 中文 + +## Problem + +`packages/ui/tui/src/index.ts` 已超过 2000 行,其中绝大部分是单个 `createTuiChat` 工厂:一个约 1600 行的闭包,持有约四十个可变变量以及同等数量的嵌套闭包。模型选择、ask-user-question 队列、会话恢复都缠绕在这一个作用域里,读者无法在不把整份文件装进脑子的前提下理清任何单一关注点,互不相关的改动也会彼此冲突。此前一轮已把 `src/` 归组为 `components/`、`session/`、`extension/`,但入口文件本身以及散落在顶层的输入相关文件(`autocomplete.ts`、`file-autocomplete.ts`、`skill-invocation.ts`、`xml-tool-output.ts`)未动。 + +## Decision + +聊天通道内聚的子机制从 `createTuiChat` 中抽出,迁入 `src/chat/`,每个都是接收显式依赖包的工厂,而非闭包捕获入口作用域: + +- `chat/model-command.ts` — `createModelController`:排队执行的 `/model` 命令、模型加推理力度(reasoning-effort)的选择浮层,以及所选模型上下文窗口的解析。持有供提示行与状态视图读取的上下文窗口缓存。 +- `chat/questions.ts` — `createQuestionQueue`:user-interaction provider 以及一次仅一个的 FIFO ask-user-question 浮层。 +- `chat/resume.ts` — `createResumeController`:`/resume` 选择器、逐候选摘要读取、交接前预检、终端交接,以及持久化的恢复提示命令。 +- `chat/helpers.ts` — 无状态辅助函数(`formatCwd`、`gitBranch`、surface/工具调用派生、会话引用卡片)、`HintEditor`,以及横幅揭示常量。 +- `chat/channel.ts` — `ChatChannelDeps`(每个子控制器共享的协作者面)与 `ChannelNotice`(由需要上报结果的控制器混入)。各 `*Deps` 继承它们,使共享面只有一处定义。 + +`src/` 随之重组,使 `chat/` 汇集所有聊天通道关注点:上述子控制器,加上原来的输入文件与原 `session/` 文件(`timing.ts`、`tokens.ts`)都迁到 `chat/` 之下。`xml-tool-output.ts` 迁到 `components/` 之下。宿主/进程边界接口(`TuiRuntime`、`TuiResumeHost`)迁到 `src/runtime.ts`。拆分后 `src/` 为 `chat/`、`components/`、`extension/`,以及顶层的 `index.ts` / `config.ts` / `prompt.ts` / `runtime.ts` / `invariant.ts`;`index.ts` 从 2067 行降至约 1530 行,现负责构造并接线这三个控制器。 + +控制器依赖包的约定:稳定的取值型协作者(`ctx`、`resolved`、`palette`、`overlayManager`,以及各控制器自有的服务)一次性解构;通道回调(`appendNotice`、`requestRender`、`isDisposed`、`agentStatus`)保留在 `deps` 上,使控制器始终调用通道当前的实现。`channel.ts` 的 JSDoc 陈述了此规则。 + +## Alternatives considered + +- **接收共享可变上下文对象的自由函数。** 否决:那会把拆分本要消除的四十字段大杂烩,仅换个参数名重新暴露出来。 +- **同时抽出状态/计时动画控制器。** 推迟:`runningStatus` 被 `updatePromptValues` 中的提示光标动画直接读取,在此设控制器边界会让其内部状态经 getter 反向泄漏——收益甚微的漏隙缝。它继续内联在 `index.ts` 中。 + +## Consequences + +每个关注点现可独立阅读与测试,共享依赖面只定义一次,而非复制进三个接口。代价:`index.ts` 负责构造这些控制器并穿针引线地传入回调包;模型控制器是 `let` 前向引用(`updatePromptValues` 闭包捕获它,但它要待 `appendNotice`/`overlayManager` 就绪后才构造),因而带一处有正当理由的 `prefer-const` 禁用与一次延后的首帧绘制。 + +## Testing + +行为不变:现有的包测试与 TUI 快照全部无需重录即通过,这正是本次重构的契约。 diff --git a/packages/ui/tui/src/autocomplete.ts b/packages/ui/tui/src/chat/autocomplete.ts similarity index 97% rename from packages/ui/tui/src/autocomplete.ts rename to packages/ui/tui/src/chat/autocomplete.ts index d8a639ad2f..d63ed3614d 100644 --- a/packages/ui/tui/src/autocomplete.ts +++ b/packages/ui/tui/src/chat/autocomplete.ts @@ -1,7 +1,7 @@ /** * Editor autocomplete provider merging path-only file candidates and optional * session-reference snapshots with the base slash-command completions. - * @module @deepseek-ai/dsh-tui/autocomplete + * @module @deepseek-ai/dsh-tui/chat/autocomplete */ import { @@ -15,7 +15,7 @@ import { formatSessionReferenceMention, type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' -import { displayInlineText } from './components/text.ts' +import { displayInlineText } from '../components/text.ts' import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts' /** Merge path-only file candidates and optional session snapshots with commands. */ diff --git a/packages/ui/tui/src/chat/channel.ts b/packages/ui/tui/src/chat/channel.ts new file mode 100644 index 0000000000..bb46aad780 --- /dev/null +++ b/packages/ui/tui/src/chat/channel.ts @@ -0,0 +1,31 @@ +/** + * Shared collaborator surface every chat-channel sub-controller receives from + * `createTuiChat`. Each controller's own `*Deps` extends {@link ChatChannelDeps} + * (and {@link ChannelNotice} when it reports outcomes) with the extra services + * it needs. Value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`) + * are stable for the channel's life; the callbacks stay on the object so a + * controller always calls the channel's current implementation. + * @module @deepseek-ai/dsh-tui/chat/channel + */ + +import type { Context } from 'cordis' +import type { TuiOverlayManager } from '../extension/overlay-manager.ts' +import type { Palette } from '../components/theme.ts' +import type { ResolvedTuiConfig } from '../config.ts' + +/** Collaborators shared by every chat-channel sub-controller. */ +export interface ChatChannelDeps { + readonly ctx: Context + readonly resolved: ResolvedTuiConfig + readonly palette: Palette + readonly overlayManager: TuiOverlayManager + /** Redraw the channel. */ + requestRender(): void + /** Whether the channel has begun shutting down. */ + isDisposed(): boolean +} + +/** Append a channel notice line; controllers that report outcomes mix this in. */ +export interface ChannelNotice { + appendNotice(message: string, kind?: 'info' | 'warning' | 'error'): void +} diff --git a/packages/ui/tui/src/file-autocomplete.ts b/packages/ui/tui/src/chat/file-autocomplete.ts similarity index 99% rename from packages/ui/tui/src/file-autocomplete.ts rename to packages/ui/tui/src/chat/file-autocomplete.ts index 23a3a7c1fa..5bd4282d5d 100644 --- a/packages/ui/tui/src/file-autocomplete.ts +++ b/packages/ui/tui/src/chat/file-autocomplete.ts @@ -3,7 +3,7 @@ * paths only: selected values remain ordinary prompt text and file contents * stay behind the model-facing `read` tool. * - * @module @deepseek-ai/dsh-tui/file-autocomplete + * @module @deepseek-ai/dsh-tui/chat/file-autocomplete */ import { lstat, readdir } from 'node:fs/promises' diff --git a/packages/ui/tui/src/chat/helpers.ts b/packages/ui/tui/src/chat/helpers.ts new file mode 100644 index 0000000000..a01ad640a7 --- /dev/null +++ b/packages/ui/tui/src/chat/helpers.ts @@ -0,0 +1,151 @@ +/** + * Zero-state helpers for the interactive chat channel: prompt-directory and + * Git-branch formatting, surface/tool-call derivations over the session log, + * session-reference context cards, the placeholder editor, and banner-reveal + * timing constants. None of these close over channel state. + * @module @deepseek-ai/dsh-tui/chat/helpers + */ + +import { execFileSync } from 'node:child_process' +import { homedir } from 'node:os' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import { + CURSOR_MARKER, + Editor, + truncateToWidth, + visibleWidth, +} from '@earendil-works/pi-tui' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +/** Editor that shows a placeholder without making it editable content. */ +export class HintEditor extends Editor { + /** Placeholder shown in the empty input row; `undefined` hides it. */ + hint: string | undefined + /** Prompt text rendered before the placeholder, matching the live prompt width. */ + hintPrefix = '' + + override render(width: number): string[] { + const lines = super.render(width) + if (this.hint === undefined || this.getText() !== '') return lines + const content = lines[0] + /* v8 ignore next -- Editor always renders one content row. */ + if (content === undefined) return lines + const padding = ' '.repeat(this.getPaddingX()) + /* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */ + const marker = this.focused ? CURSOR_MARKER : '' + const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix)) + const placeholder = truncateToWidth(this.hint, available, '') + const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder) + lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}` + return lines + } +} + +/** + * Format the session working directory as a prompt label: `~` for home, + * `~/rel` for a home-relative path, the raw path otherwise. + * @param cwd - operational working directory from the session header. + * @returns unescaped prompt label. + */ +export 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 +} + +/** + * Resolve the current Git branch for the prompt context line. + * @param cwd - operational working directory to query. + * @returns branch name, or `undefined` outside a worktree or on any failure. + */ +export function gitBranch(cwd: string): string | undefined { + try { + const env = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)), + ) + const branch = execFileSync('git', ['branch', '--show-current'], { + cwd, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 1_000, + }).trim() + /* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */ + return branch === '' ? undefined : branch + } catch (_gitUnavailableOrOutsideWorktree) { + return undefined + } +} + +/** + * Sequence numbers currently visible on the session surface. + * @param session - session whose surface nodes to read. + * @returns the set of visible event sequence numbers. + */ +export function activeSurfaceSeqs(session: Session): Set { + return new Set(session.surface.nodes) +} + +/** + * Tool-call ids whose owning assistant message is on the active surface. + * @param session - session whose events to scan. + * @param active - sequence numbers currently on the surface. + * @returns the set of active tool-call ids. + */ +export 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 +} + +/** + * Read a session-reference context card's display labels from event meta. + * @param meta - envelope-context meta to inspect. + * @returns per-reference labels, or `undefined` when meta is not a reference card. + */ +export function sessionReferenceCard(meta: unknown): string[] | undefined { + if (typeof meta !== 'object' || meta === null) return undefined + const record = meta as Record + if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined + const references = record['references'] as unknown[] + const labels: string[] = [] + for (const reference of references) { + if (typeof reference !== 'object' || reference === null) return undefined + const entry = reference as Record + const sessionId = entry['sessionId'] + const label = entry['label'] + if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined + labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`) + } + return labels +} + +/** + * Session-reference cards attached to a prompt or steering message envelope. + * @param event - the user or steering message event to read. + * @returns per-envelope-context reference-label lists, empty when none. + */ +export function promptReferenceCards( + event: Extract, +): string[][] { + return event.data.envelope?.prefixContexts.flatMap((context) => { + const card = sessionReferenceCard(context.meta) + return card === undefined ? [] : [card] + }) ?? [] +} + +/** Milliseconds between banner sweep-reveal frames (~60 fps). */ +export const BANNER_REVEAL_INTERVAL_MS = 15 + +/** Number of sweep frames the banner reveal spreads the terminal width over. */ +export const BANNER_REVEAL_STEPS = 24 diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts new file mode 100644 index 0000000000..133a3d0d9f --- /dev/null +++ b/packages/ui/tui/src/chat/model-command.ts @@ -0,0 +1,191 @@ +/** + * Model-selection sub-controller for the interactive chat channel: the queued + * `/model` command, the keyboard model selector overlay with reasoning-effort + * selection, and resolution of the selected model's context window. Owns the + * context-window cache the prompt and status views read; the caller owns the + * shared {@link AgentLlmTargetRef}. + * @module @deepseek-ai/dsh-tui/chat/model-command + */ + +import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' +import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { TuiOverlaySession } from '../extension/types.ts' +import { displayText } from '../components/text.ts' +import { + ModelDialog, + readModelChoices, + targetLabel, + targetReasoningLabel, + type ModelChoice, + type ModelDialogSelection, +} from '../components/dialogs.ts' +import type { ChannelNotice, ChatChannelDeps } from './channel.ts' + +/** Collaborators the model controller needs from the chat channel. */ +export interface ModelControllerDeps extends ChatChannelDeps, ChannelNotice { + /** Shared selected-target handle owned by the channel. */ + readonly target: AgentLlmTargetRef +} + +/** Model-selection controller for one chat channel. */ +export interface ModelController { + /** Resolved context window of the selected model, or `undefined` if unknown. */ + contextWindow(): number | undefined + /** Queue a `/model` command; empty argument opens the selector. */ + queueModelCommand(raw: string): void + /** Drop the pending context-window resolution (shutdown). */ + resetContextResolution(): void + /** Forget the tracked selector overlay (shutdown). */ + clearOverlay(): void +} + +type ContextResolution = + | { readonly kind: 'resolved'; readonly contextWindow: number | undefined } + | { readonly kind: 'error'; readonly error: unknown } + +/** + * Build the model-selection controller for one chat channel. + * @param deps - channel collaborators and shared target handle. + * @returns the controller wired to the channel's overlay and prompt views. + */ +export function createModelController(deps: ModelControllerDeps): ModelController { + const { ctx, resolved, palette, overlayManager, target } = deps + let contextWindow: number | undefined + let contextResolution: Promise | undefined + let modelOverlay: TuiOverlaySession | undefined + let modelCommands = Promise.resolve() + + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { + contextWindow = undefined + const resolution: Promise = selected === undefined + ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) + : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( + info => ({ kind: 'resolved', contextWindow: info.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') { + deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') + return + } + contextWindow = result.contextWindow + deps.requestRender() + }) + } + resolveContextWindow(target.current) + + const selectModel = ( + selected: ModelChoice, + explicitReasoning?: { effort: ReasoningEffortId | undefined }, + ): void => { + const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model + const reasoningEffort = explicitReasoning === undefined + ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) + : explicitReasoning.effort + if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { + const reasoning = targetReasoningLabel(selected, reasoningEffort) + deps.appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) + return + } + target.current = { + provider: selected.provider, + model: selected.model, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + } + resolveContextWindow(target.current) + const reasoning = targetReasoningLabel(selected, reasoningEffort) + deps.appendNotice([ + `Model selected: ${targetLabel(selected)}.`, + ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], + 'New steps will use it.', + ].join(' ')) + } + + const showModelSelector = (choices: readonly ModelChoice[]): void => { + const current = target.current === undefined ? 'unset' : targetLabel(target.current) + if (choices.length === 0) { + deps.appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning') + return + } + void modelOverlay?.close() + const session = overlayManager.open({ + create: () => new ModelDialog( + choices, + target.current, + resolved.maxModelOptions, + palette, + (selection: ModelDialogSelection) => { + void session.close() + selectModel(selection.choice, { effort: selection.reasoningEffort }) + }, + () => { void session.close() }, + ), + options: { + width: resolved.modelDialogWidth, + maxHeight: resolved.modelDialogMaxHeight, + anchor: 'center', + margin: 1, + }, + }) + modelOverlay = session + void session.closed.then(() => { + if (modelOverlay === session) modelOverlay = undefined + }) + deps.requestRender() + } + + const handleModelCommand = async (raw: string): Promise => { + const choices = await readModelChoices(ctx, target.current) + if (deps.isDisposed()) return + const argument = raw.trim() + if (argument === '') { + showModelSelector(choices) + return + } + const parts = argument.split(/\s+/u) + if (parts.length > 2) { + deps.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) { + deps.appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning') + return + } + if (matches.length > 1) { + deps.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) + } + + return { + contextWindow: () => contextWindow, + queueModelCommand(raw: string): void { + modelCommands = modelCommands.then(async () => { + await handleModelCommand(raw) + }).catch((error: unknown) => { + if (!deps.isDisposed()) deps.appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error') + }) + }, + resetContextResolution(): void { + contextResolution = undefined + }, + clearOverlay(): void { + modelOverlay = undefined + }, + } +} diff --git a/packages/ui/tui/src/chat/questions.ts b/packages/ui/tui/src/chat/questions.ts new file mode 100644 index 0000000000..e5f2c8b806 --- /dev/null +++ b/packages/ui/tui/src/chat/questions.ts @@ -0,0 +1,168 @@ +/** + * Ask-user-question sub-machine for the interactive chat channel. Registers the + * user-interaction provider, presents one question overlay at a time in FIFO + * order, and settles each request on answer, abort, overlay error, or channel + * shutdown. + * @module @deepseek-ai/dsh-tui/chat/questions + */ + +import { errorChain } from '@deepseek-ai/dsh-llm' +import { + UserInteractionError, + type AskUserQuestionAnswer, + type AskUserQuestionAnswerItem, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-interaction' +import type { TuiOverlaySession } from '../extension/types.ts' +import { QuestionDialog } from '../components/dialogs.ts' +import type { ChatChannelDeps } from './channel.ts' + +/** One queued or active ask-user-question request and its running answers. */ +interface PendingQuestion { + request: AskUserQuestionRequest + index: number + answers: AskUserQuestionAnswerItem[] + resolve(answer: AskUserQuestionAnswer): void + reject(error: unknown): void + onAbort: () => void + overlay: TuiOverlaySession | undefined +} + +/** Collaborators the question queue needs from the chat channel. */ +export type QuestionQueueDeps = ChatChannelDeps + +/** Ask-user-question controller for one chat channel. */ +export interface QuestionQueue { + /** Reject the active and all queued questions (shutdown). */ + rejectAll(): void + /** Remove the user-interaction provider registration. */ + unregister(): void +} + +/** + * Build the ask-user-question queue for one chat channel. + * @param deps - channel collaborators and overlay host. + * @returns the controller used at shutdown to drain and unregister. + */ +export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue { + const { ctx, resolved, palette, overlayManager } = deps + const questionQueue: PendingQuestion[] = [] + let activeQuestion: PendingQuestion | undefined + + const removeAbortListener = (pending: PendingQuestion): void => { + pending.request.signal?.removeEventListener('abort', pending.onAbort) + } + + const rejectQuestion = (pending: PendingQuestion): void => { + void pending.overlay?.close() + 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 || deps.isDisposed()) 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 session = overlayManager.open({ + ...pending.request.signal === undefined ? {} : { signal: pending.request.signal }, + create: () => new QuestionDialog( + question, + pending.index + 1, + pending.request.questions.length, + pending.request.questions.length - pending.answers.length, + resolved.maxQuestionOptions, + palette, + (selection) => { + pending.overlay = undefined + void session.close() + pending.answers.push({ id: question.id, ...selection }) + pending.index += 1 + show() + }, + () => { + activeQuestion = undefined + rejectQuestion(pending) + startNextQuestion() + }, + ), + options: { + width: resolved.questionDialogWidth, + maxHeight: resolved.questionDialogMaxHeight, + anchor: 'bottom-left', + margin: { bottom: 1 }, + }, + }) + pending.overlay = session + void session.closed.then((result) => { + if (pending.overlay !== session) return + pending.overlay = undefined + /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */ + if (result.reason !== 'error') return + activeQuestion = undefined + removeAbortListener(pending) + pending.reject(new UserInteractionError( + `ask_user_question TUI failed: ${errorChain(result.error)}`, + 'ASK_ABORTED', + )) + startNextQuestion() + }) + deps.requestRender() + } + show() + } + + const unregister = 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() + }) + }, + }) + + return { + rejectAll(): void { + if (activeQuestion !== undefined) { + const pending = activeQuestion + activeQuestion = undefined + rejectQuestion(pending) + } + for (const pending of questionQueue.splice(0)) rejectQuestion(pending) + }, + unregister, + } +} diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts new file mode 100644 index 0000000000..2f0fdd423c --- /dev/null +++ b/packages/ui/tui/src/chat/resume.ts @@ -0,0 +1,245 @@ +/** + * Session-resume sub-controller for the interactive chat channel: the + * `/resume` selector, per-candidate summary reads that tolerate a corrupt + * neighbor, the pre-handoff preflight, the terminal handoff itself, and the + * durable resume-hint command printed on exit. + * @module @deepseek-ai/dsh-tui/chat/resume + */ + +import type { TUI } from '@earendil-works/pi-tui' +import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' +import { errorChain } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session' +import type { + SessionLogSnapshot, + SessionQueryService, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { HintEditor } from './helpers.ts' +import { formatCwd } from './helpers.ts' +import type { TuiOverlaySession } from '../extension/types.ts' +import type { TuiRuntime } from '../runtime.ts' +import type { Config } from '../config.ts' +import { + ResumePicker, + summarizeResumeCandidate, + type ResumeCandidate, +} from '../components/dialogs.ts' +import type { ChannelNotice, ChatChannelDeps } from './channel.ts' + +/** Collaborators the resume controller needs from the chat channel. */ +export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice { + readonly agent: Agent + readonly config: Config + readonly runtime: TuiRuntime + readonly persistence: SessionPersistence | undefined + readonly sessionQuery: SessionQueryService | undefined + readonly ui: TUI + readonly editor: HintEditor + /** Current agent status, re-read at each resume precondition point. */ + agentStatus(): AgentStatus +} + +/** Session-resume controller for one chat channel. */ +export interface ResumeController { + /** Open the current-workspace searchable session selector. */ + showResume(): void + /** + * The resume command for the current session — the configured template with + * every `{session}` filled — but only once the session is durably persisted; + * `undefined` otherwise. + */ + currentResumeCommand(): Promise +} + +/** + * Build the session-resume controller for one chat channel. + * @param deps - channel collaborators, terminal handles, and optional services. + * @returns the controller wired to the `/resume` command and exit hint. + */ +export function createResumeController(deps: ResumeControllerDeps): ResumeController { + const { + ctx, agent, config, runtime, resolved, palette, overlayManager, + persistence, sessionQuery, ui, editor, + } = deps + let resumeOverlay: TuiOverlaySession | undefined + let resumeInFlight = false + let resumeScan = 0 + + /** + * Persisted sessions for this workspace, newest first. Empty when no + * persistence backend is mounted or a listing failure would otherwise block + * exit or crash `/resume`; the resume hint is best-effort convenience. + */ + const listWorkspaceSessions = async (): Promise => { + if (persistence === undefined) return [] + let all: readonly SessionHeader[] + try { + all = await persistence.list() + } catch { + // A listing failure must never block terminal exit or crash `/resume`. + return [] + } + return all + .filter(header => header.cwd === agent.session.header.cwd) + } + + /** Build one display candidate without letting a corrupt neighbor abort the selector. */ + const readResumeCandidate = async ( + record: SessionRecord, + providers: ReadonlySet, + ): Promise => { + try { + let snapshot: SessionLogSnapshot + const live = ctx.sessions.get(record.header.id) + if (live !== undefined) { + snapshot = { + session: structuredClone(live.header), + events: live.events.map(event => structuredClone(event)), + } + } else { + /* v8 ignore next -- caller checks the optional service before mapping records */ + if (sessionQuery === undefined) throw new Error('session query is unavailable') + snapshot = await sessionQuery.readSession(record.header.id) + } + return summarizeResumeCandidate( + record, + snapshot, + agent.session.id, + agent.session.header.cwd, + providers, + ) + } catch (error: unknown) { + return { + record, + title: 'Unreadable session', + lastActivityAt: record.header.createdAt, + lastTurn: 'log unavailable', + disabledReason: `session cannot be loaded: ${errorChain(error)}`, + } + } + } + + /** Re-read every mutable precondition immediately before terminal handoff. */ + const preflightResume = async (sessionId: SessionId): Promise => { + /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ + if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') + const initialStatus = deps.agentStatus() + if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) + const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) + if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) + const candidate = await readResumeCandidate( + record, + new Set(ctx.llm.listProviders().map(provider => provider.id)), + ) + if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) + const finalStatus = deps.agentStatus() + if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) + return candidate + } + + const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { + if (resumeInFlight) return + resumeInFlight = true + let terminalReleased = false + try { + const checked = await preflightResume(candidate.record.header.id) + const hostHandoff = runtime.handoffResume + if (hostHandoff === undefined) { + const template = config.resumeCommand + const fallback = template?.replaceAll('{session}', checked.record.header.id) + await overlay.close() + resumeOverlay = undefined + deps.appendNotice(fallback === undefined + ? 'Session is resumable, but this host cannot hand it off in place.' + : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') + return + } + /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ + if (deps.isDisposed()) return + await ctx.sessions.flush(agent.session) + // Disposal can run while the flush promise is pending. + if (deps.isDisposed()) return + if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) + await overlay.close() + resumeOverlay = undefined + await runtime.terminal.drainInput(100, 20) + // Disposal can run while terminal draining is pending. + if (deps.isDisposed()) return + ui.stop() + terminalReleased = true + await hostHandoff(checked.record.header.id) + throw new Error('resume host returned without replacing the process') + } catch (error: unknown) { + if (!deps.isDisposed()) { + if (terminalReleased) { + ui.start() + ui.setFocus(editor) + deps.appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + } else { + await overlay.close() + resumeOverlay = undefined + deps.appendNotice(`Resume failed: ${errorChain(error)}`, 'error') + } + } + } finally { + resumeInFlight = false + } + } + + return { + currentResumeCommand: async (): Promise => { + if (config.resumeCommand === undefined) return undefined + const sessions = await listWorkspaceSessions() + if (!sessions.some(header => header.id === agent.session.id)) return undefined + return config.resumeCommand.replaceAll('{session}', agent.session.id) + }, + showResume(): void { + if (agent.status !== 'idle') { + deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') + return + } + if (sessionQuery === undefined) { + deps.appendNotice('Resume is not available: session query is not mounted.', 'warning') + return + } + const scan = ++resumeScan + void resumeOverlay?.close() + void sessionQuery.listSessions().then(async (records) => { + if (deps.isDisposed() || scan !== resumeScan) return + const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) + const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) + const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) + candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt + || a.record.header.id.localeCompare(b.record.header.id)) + if (deps.isDisposed() || scan !== resumeScan) return + const session = overlayManager.open({ + create: host => new ResumePicker( + candidates, + resolved.maxResumeOptions, + runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), + () => host.viewport.rows, + palette, + (candidate) => { void handoffResume(candidate, session) }, + () => { void session.close() }, + ), + options: { + width: '100%', + maxHeight: '100%', + anchor: 'top-left', + margin: 0, + }, + }) + resumeOverlay = session + void session.closed.then(() => { + /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ + if (resumeOverlay === session) resumeOverlay = undefined + }) + deps.requestRender() + }, (error: unknown) => { + if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') + }) + }, + } +} diff --git a/packages/ui/tui/src/skill-invocation.ts b/packages/ui/tui/src/chat/skill-invocation.ts similarity index 98% rename from packages/ui/tui/src/skill-invocation.ts rename to packages/ui/tui/src/chat/skill-invocation.ts index 5857e93ea3..7eb7a555ae 100644 --- a/packages/ui/tui/src/skill-invocation.ts +++ b/packages/ui/tui/src/chat/skill-invocation.ts @@ -1,7 +1,7 @@ /** * Manual `/skill: [instructions]` parsing and model-visible rendering for * the terminal front door. - * @module @deepseek-ai/dsh-tui/skill-invocation + * @module @deepseek-ai/dsh-tui/chat/skill-invocation */ import { assertNever } from '@deepseek-ai/dsh-llm' diff --git a/packages/ui/tui/src/session/timing.ts b/packages/ui/tui/src/chat/timing.ts similarity index 99% rename from packages/ui/tui/src/session/timing.ts rename to packages/ui/tui/src/chat/timing.ts index d96d598202..13477adfa0 100644 --- a/packages/ui/tui/src/session/timing.ts +++ b/packages/ui/tui/src/chat/timing.ts @@ -3,7 +3,7 @@ * front door. Timing buckets are replayed from the session event stream; the * running glyph fades in on turn start, throbs while the turn runs, and fades * out on turn end. - * @module @deepseek-ai/dsh-tui/session/timing + * @module @deepseek-ai/dsh-tui/chat/timing */ import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/ui/tui/src/session/tokens.ts b/packages/ui/tui/src/chat/tokens.ts similarity index 98% rename from packages/ui/tui/src/session/tokens.ts rename to packages/ui/tui/src/chat/tokens.ts index 1711c96ede..ab54292a1d 100644 --- a/packages/ui/tui/src/session/tokens.ts +++ b/packages/ui/tui/src/chat/tokens.ts @@ -1,7 +1,7 @@ /** * Running token accounting for the terminal footer. Usage is keyed per * turn/step so replayed or re-emitted usage replaces rather than double-counts. - * @module @deepseek-ai/dsh-tui/session/tokens + * @module @deepseek-ai/dsh-tui/chat/tokens */ import type { TokenUsage } from '@deepseek-ai/dsh-llm' diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 41835ee31f..b01ffc5e48 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -25,7 +25,7 @@ import type { ToolResultView, } from '@deepseek-ai/dsh-tools' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { renderUnknownXml } from '../xml-tool-output.ts' +import { renderUnknownXml } from './xml-tool-output.ts' import { displayInlineText, displayText } from './text.ts' import { gradientText, type Palette } from './theme.ts' import { contentText, type ParsedArguments } from './content.ts' @@ -34,7 +34,7 @@ import { formatTimingTotals, stepTimingAt, type StepPosition, -} from '../session/timing.ts' +} from '../chat/timing.ts' /** Concatenate the text of every block of one type, separated by blank lines. */ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { diff --git a/packages/ui/tui/src/xml-tool-output.ts b/packages/ui/tui/src/components/xml-tool-output.ts similarity index 100% rename from packages/ui/tui/src/xml-tool-output.ts rename to packages/ui/tui/src/components/xml-tool-output.ts diff --git a/packages/ui/tui/src/config.ts b/packages/ui/tui/src/config.ts index a0114be972..a48a1d704c 100644 --- a/packages/ui/tui/src/config.ts +++ b/packages/ui/tui/src/config.ts @@ -10,7 +10,7 @@ import { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' +} from './chat/file-autocomplete.ts' /** Theme and prompt-template settings for the pi-tui terminal mode. */ export interface TuiThemeConfig { diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 1dffff23e9..2a682c6689 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -5,25 +5,18 @@ * @module @deepseek-ai/dsh-tui */ -import { execFileSync } from 'node:child_process' -import { homedir } from 'node:os' -import { isAbsolute, relative, resolve, sep } from 'node:path' import { CombinedAutocompleteProvider, Container, - CURSOR_MARKER, - Editor, Key, Spacer, Text, TUI, ProcessTerminal, matchesKey, - truncateToWidth, visibleWidth, type EditorTheme, type SlashCommand, - type Terminal, type TerminalColorScheme, } from '@earendil-works/pi-tui' import { Service, type Context, type Fiber } from 'cordis' @@ -31,7 +24,6 @@ import { assembleContextFor, installAgentLlmTarget, type Agent, - type AgentLlmTarget, type AgentLlmTargetRef, type AgentStatus, type HookContext, @@ -40,36 +32,27 @@ import type {} from '@deepseek-ai/dsh-agent-loop' import type {} from '@deepseek-ai/dsh-token-meter' import type { CommandResult } from '@deepseek-ai/dsh-commands' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { renderUnknownXml } from './xml-tool-output.ts' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { renderUnknownXml } from './components/xml-tool-output.ts' import type {} from '@deepseek-ai/dsh-llm-retry' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { displayPromptContent, SessionId, - type Session, type SessionEvent, - type SessionHeader, } from '@deepseek-ai/dsh-session' import { foldGoal } from '@deepseek-ai/dsh-goal' import { parseSessionReferenceText, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' -import type { - SessionLogSnapshot, - SessionRecord, -} from '@deepseek-ai/dsh-session-query' // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' import type { SkillService } from '@deepseek-ai/dsh-skill' -import { - UserInteractionError, - type AskUserQuestionAnswer, - type AskUserQuestionAnswerItem, - type AskUserQuestionRequest, -} from '@deepseek-ai/dsh-user-interaction' +// Type import declaration-merges the `userInteraction` service onto `Context`; +// the ask-user-question queue is registered by ./chat/questions. +import type {} from '@deepseek-ai/dsh-user-interaction' import { TuiExtensionServiceImpl, TuiOverlayManager, @@ -92,7 +75,7 @@ import { formatTokens, recordEventUsage, sessionTokens, -} from './session/tokens.ts' +} from './chat/tokens.ts' import { fadeGlyph, formatQueuedStatus, @@ -104,7 +87,7 @@ import { STATUS_FADE_MS, TIMING_BUCKET_GLYPHS, type StepPosition, -} from './session/timing.ts' +} from './chat/timing.ts' import { resolveTuiConfig, type Config, @@ -123,30 +106,40 @@ import { formatDiagnosticNumber, formatDiagnosticTime, initialTarget, - ModelDialog, - QuestionDialog, - readModelChoices, - ResumePicker, StatusCardComponent, PromptContextComponent, - summarizeResumeCandidate, targetLabel, - targetReasoningLabel, - type ModelChoice, - type ModelDialogSelection, - type ResumeCandidate, type StatusCardRow, } from './components/dialogs.ts' import { parseSkillCommand, renderSkillInvocation, SKILL_COMMAND_PREFIX, -} from './skill-invocation.ts' -import { ReferenceAutocompleteProvider } from './autocomplete.ts' -import { WorkspaceFileSearch } from './file-autocomplete.ts' +} from './chat/skill-invocation.ts' +import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts' +import { + activeSurfaceSeqs, + activeToolCallIds, + BANNER_REVEAL_INTERVAL_MS, + BANNER_REVEAL_STEPS, + formatCwd, + gitBranch, + HintEditor, + promptReferenceCards, + sessionReferenceCard, +} from './chat/helpers.ts' +import { + createModelController, + type ModelController, +} from './chat/model-command.ts' +import { createQuestionQueue } from './chat/questions.ts' +import { createResumeController } from './chat/resume.ts' +import type { TuiResumeHost, TuiRuntime } from './runtime.ts' +import { WorkspaceFileSearch } from './chat/file-autocomplete.ts' export { TuiPromptService } from './prompt.ts' -export { renderSkillInvocation } from './skill-invocation.ts' +export { renderSkillInvocation } from './chat/skill-invocation.ts' +export type { TuiResumeHost, TuiRuntime } from './runtime.ts' export { resolveTuiConfig, TuiConfigSchema, @@ -160,7 +153,7 @@ export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' +} from './chat/file-autocomplete.ts' export type { TuiComponent, @@ -187,17 +180,6 @@ declare module 'cordis' { } } -/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ -export interface TuiResumeHost { - /** - * Dispose the current app and replace it with a runtime for `sessionId`. - * Success does not return. A host may reject before it commits teardown; - * after commit it owns fatal reporting and process exit. - * @param sessionId - validated persisted session selected by the user. - */ - handoff(sessionId: SessionId): Promise -} - /** * Optional terminal-local interaction service provided by one mounted TUI. * @@ -228,52 +210,6 @@ export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'too /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' -/** 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 prompt'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 - /** - * Override the Git branch shown in the prompt context line; production resolves it once at mount. - * @param cwd - Operational working directory from the session header. - * @returns Unescaped branch name, or `undefined` outside a Git worktree. - */ - gitBranch?: (cwd: string) => string | undefined - /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ - now?(): number - /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ - handoffResume?: TuiResumeHost['handoff'] -} - -/** Editor that shows a placeholder without making it editable content. */ -class HintEditor extends Editor { - hint: string | undefined - hintPrefix = '' - - override render(width: number): string[] { - const lines = super.render(width) - if (this.hint === undefined || this.getText() !== '') return lines - const content = lines[0] - /* v8 ignore next -- Editor always renders one content row. */ - if (content === undefined) return lines - const padding = ' '.repeat(this.getPaddingX()) - /* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */ - const marker = this.focused ? CURSOR_MARKER : '' - const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix)) - const placeholder = truncateToWidth(this.hint, available, '') - const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder) - lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}` - return lines - } -} - interface RunningStatus { turn: number | undefined timer: ReturnType @@ -291,97 +227,12 @@ interface FadingStatus { timer: ReturnType } -interface PendingQuestion { - request: AskUserQuestionRequest - index: number - answers: AskUserQuestionAnswerItem[] - resolve(answer: AskUserQuestionAnswer): void - reject(error: unknown): void - onAbort: () => void - overlay: TuiOverlaySession | undefined -} - /** Lifecycle handle for a mounted interactive terminal channel. */ export interface TuiController { /** Stop rendering, restore the terminal, and reject pending questions. */ dispose(): Promise } -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 -} - -function gitBranch(cwd: string): string | undefined { - try { - const env = Object.fromEntries( - Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)), - ) - const branch = execFileSync('git', ['branch', '--show-current'], { - cwd, - encoding: 'utf8', - env, - stdio: ['ignore', 'pipe', 'ignore'], - timeout: 1_000, - }).trim() - /* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */ - return branch === '' ? undefined : branch - } catch (_gitUnavailableOrOutsideWorktree) { - return undefined - } -} - -function activeSurfaceSeqs(session: Session): Set { - return new Set(session.surface.nodes) -} - -function sessionReferenceCard(meta: unknown): string[] | undefined { - if (typeof meta !== 'object' || meta === null) return undefined - const record = meta as Record - if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined - const references = record['references'] as unknown[] - const labels: string[] = [] - for (const reference of references) { - if (typeof reference !== 'object' || reference === null) return undefined - const entry = reference as Record - const sessionId = entry['sessionId'] - const label = entry['label'] - if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined - labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`) - } - return labels -} - -function promptReferenceCards(event: Extract): string[][] { - return event.data.envelope?.prefixContexts.flatMap((context) => { - const card = sessionReferenceCard(context.meta) - return card === undefined ? [] : [card] - }) ?? [] -} - -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 -} - -/** Milliseconds between banner sweep-reveal frames (~60 fps). */ -const BANNER_REVEAL_INTERVAL_MS = 15 - -/** Number of sweep frames the banner reveal spreads the terminal width over. */ -const BANNER_REVEAL_STEPS = 24 - /** * Start the interactive pi-tui channel for an already-created target agent. * @param ctx - agent, tools, session-event, and user-interaction context. @@ -453,22 +304,16 @@ export function createTuiChat( const toolCards = new Map() const allToolCards = new Set() const liveErrors = new Set() - const questionQueue: PendingQuestion[] = [] const commandControllers = new Set() const referenceControllers = new Set() - let activeQuestion: PendingQuestion | undefined - let modelOverlay: TuiOverlaySession | undefined - let resumeOverlay: TuiOverlaySession | undefined - let resumeInFlight = false - let resumeScan = 0 let tuiServiceFiber: Fiber | 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() + // `updatePromptValues` (defined below) closes over the model controller, but + // the controller needs `appendNotice`/`overlayManager`, defined after that + // closure. Declare here, assign once after those exist, and defer the first + // `updatePromptValues()` call until after the assignment so no read precedes it. + // eslint-disable-next-line prefer-const -- single assignment is a forward-reference, not a const. + let modelController!: ModelController const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed @@ -507,6 +352,7 @@ export function createTuiChat( const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}` modelValue.set(` ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`) tokenValue.set(` ${palette.muted(rate === undefined ? usage : `${usage} cache ${rate}%`)}`) + const contextWindow = modelController.contextWindow() contextValue.set(contextWindow === undefined ? undefined : ` ${palette.muted( `${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`, )}`) @@ -543,7 +389,6 @@ export function createTuiChat( ) indicatorValue.set(`${caret}${palette.muted(' ')}`) } - updatePromptValues() const promptContext = new PromptContextComponent( parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)), parseTuiPromptTemplate(displayInlineText(resolved.theme.rightPrompt)), @@ -622,130 +467,17 @@ export function createTuiChat( 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.resolveModelInfo(selected.provider, selected.model).then( - info => ({ kind: 'resolved', contextWindow: info.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, - explicitReasoning?: { effort: ReasoningEffortId | undefined }, - ): void => { - const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model - const reasoningEffort = explicitReasoning === undefined - ? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) - : explicitReasoning.effort - if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { - const reasoning = targetReasoningLabel(selected, reasoningEffort) - appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) - return - } - target.current = { - provider: selected.provider, - model: selected.model, - ...reasoningEffort === undefined ? {} : { reasoningEffort }, - } - resolveContextWindow(target.current) - const reasoning = targetReasoningLabel(selected, reasoningEffort) - appendNotice([ - `Model selected: ${targetLabel(selected)}.`, - ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], - 'New steps will use it.', - ].join(' ')) - } - - 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 - } - void modelOverlay?.close() - const session = overlayManager.open({ - create: () => new ModelDialog( - choices, - target.current, - resolved.maxModelOptions, - palette, - (selection: ModelDialogSelection) => { - void session.close() - selectModel(selection.choice, { effort: selection.reasoningEffort }) - }, - () => { void session.close() }, - ), - options: { - width: resolved.modelDialogWidth, - maxHeight: resolved.modelDialogMaxHeight, - anchor: 'center', - margin: 1, - }, - }) - modelOverlay = session - void session.closed.then(() => { - if (modelOverlay === session) modelOverlay = undefined - }) - 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') - }) - } + modelController = createModelController({ + ctx, + resolved, + palette, + overlayManager, + target, + appendNotice, + requestRender, + isDisposed, + }) + updatePromptValues() const renderStatus = (): void => { streaming?.invalidate() @@ -1062,147 +794,38 @@ export function createTuiChat( requestRender() } - const removeAbortListener = (pending: PendingQuestion): void => { - pending.request.signal?.removeEventListener('abort', pending.onAbort) - } - - const rejectQuestion = (pending: PendingQuestion): void => { - void pending.overlay?.close() - 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 session = overlayManager.open({ - ...pending.request.signal === undefined ? {} : { signal: pending.request.signal }, - create: () => new QuestionDialog( - question, - pending.index + 1, - pending.request.questions.length, - pending.request.questions.length - pending.answers.length, - resolved.maxQuestionOptions, - palette, - (selection) => { - pending.overlay = undefined - void session.close() - pending.answers.push({ id: question.id, ...selection }) - pending.index += 1 - show() - }, - () => { - activeQuestion = undefined - rejectQuestion(pending) - startNextQuestion() - }, - ), - options: { - width: resolved.questionDialogWidth, - maxHeight: resolved.questionDialogMaxHeight, - anchor: 'bottom-left', - margin: { bottom: 1 }, - }, - }) - pending.overlay = session - void session.closed.then((result) => { - if (pending.overlay !== session) return - pending.overlay = undefined - /* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */ - if (result.reason !== 'error') return - activeQuestion = undefined - removeAbortListener(pending) - pending.reject(new UserInteractionError( - `ask_user_question TUI failed: ${errorChain(result.error)}`, - 'ASK_ABORTED', - )) - startNextQuestion() - }) - 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 questions = createQuestionQueue({ + ctx, + resolved, + palette, + overlayManager, + requestRender, + isDisposed, }) - /** - * Persisted sessions for this workspace, newest first. Empty when no - * persistence backend is mounted or a listing failure would otherwise block - * exit or crash `/resume`; the resume hint is best-effort convenience. - */ - const listWorkspaceSessions = async (): Promise => { - if (persistence === undefined) return [] - let all: readonly SessionHeader[] - try { - all = await persistence.list() - } catch { - // A listing failure must never block terminal exit or crash `/resume`. - return [] - } - return all - .filter(header => header.cwd === agent.session.header.cwd) - } - - /** - * The resume command for the current session — the configured template with - * every `{session}` filled — but only once the session is durably persisted, - * so a session abandoned before its first flush yields no hint (resuming that - * id would fail to load). - */ - const currentResumeCommand = async (): Promise => { - if (config.resumeCommand === undefined) return undefined - const sessions = await listWorkspaceSessions() - if (!sessions.some(header => header.id === agent.session.id)) return undefined - return config.resumeCommand.replaceAll('{session}', agent.session.id) - } + const resume = createResumeController({ + ctx, + agent, + config, + runtime, + resolved, + palette, + overlayManager, + persistence, + sessionQuery, + ui, + editor, + appendNotice, + requestRender, + isDisposed, + agentStatus, + }) const shutdown = (exitProcess: boolean): Promise => { shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() - contextResolution = undefined + modelController.resetContextResolution() clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) commandControllers.clear() @@ -1210,19 +833,14 @@ export function createTuiChat( referenceControllers.clear() await tuiServiceFiber?.dispose() tuiServiceFiber = undefined - if (activeQuestion !== undefined) { - const pending = activeQuestion - activeQuestion = undefined - rejectQuestion(pending) - } - for (const pending of questionQueue.splice(0)) rejectQuestion(pending) + questions.rejectAll() await overlayManager.dispose() - modelOverlay = undefined - disposeUserInteraction() + modelController.clearOverlay() + questions.unregister() await runtime.terminal.drainInput(100, 20) ui.stop() if (exitProcess) { - const command = await currentResumeCommand() + const command = await resume.currentResumeCommand() if (command !== undefined) { runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`) } @@ -1316,6 +934,7 @@ export function createTuiChat( const latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens)) let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown` + const contextWindow = modelController.contextWindow() if (contextWindow !== undefined) { const contextPercent = Math.round(usedContext / contextWindow * 100) context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})` @@ -1437,7 +1056,7 @@ export function createTuiChat( description: 'Show or switch this session\'s model', input: { hint: '[[provider/]model]' }, handler: ({ rawInput }) => { - queueModelCommand(rawInput) + modelController.queueModelCommand(rawInput) return { kind: 'success' } }, }) @@ -1469,7 +1088,7 @@ export function createTuiChat( commandCtx.commands.register({ name: 'resume', description: 'List this workspace\'s resumable sessions', - handler: () => { showResume(); return { kind: 'success' } }, + handler: () => { resume.showResume(); return { kind: 'success' } }, }) commandCtx.commands.register({ name: 'status', @@ -1608,159 +1227,6 @@ export function createTuiChat( }) } - /** Build one display candidate without letting a corrupt neighbor abort the selector. */ - const readResumeCandidate = async ( - record: SessionRecord, - providers: ReadonlySet, - ): Promise => { - try { - let snapshot: SessionLogSnapshot - const live = ctx.sessions.get(record.header.id) - if (live !== undefined) { - snapshot = { - session: structuredClone(live.header), - events: live.events.map(event => structuredClone(event)), - } - } else { - /* v8 ignore next -- caller checks the optional service before mapping records */ - if (sessionQuery === undefined) throw new Error('session query is unavailable') - snapshot = await sessionQuery.readSession(record.header.id) - } - return summarizeResumeCandidate( - record, - snapshot, - agent.session.id, - agent.session.header.cwd, - providers, - ) - } catch (error: unknown) { - return { - record, - title: 'Unreadable session', - lastActivityAt: record.header.createdAt, - lastTurn: 'log unavailable', - disabledReason: `session cannot be loaded: ${errorChain(error)}`, - } - } - } - - /** Re-read every mutable precondition immediately before terminal handoff. */ - const preflightResume = async (sessionId: SessionId): Promise => { - /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ - if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') - const initialStatus = agentStatus() - if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) - const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) - if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) - const candidate = await readResumeCandidate( - record, - new Set(ctx.llm.listProviders().map(provider => provider.id)), - ) - if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) - const finalStatus = agentStatus() - if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) - return candidate - } - - const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { - if (resumeInFlight) return - resumeInFlight = true - let terminalReleased = false - try { - const checked = await preflightResume(candidate.record.header.id) - const hostHandoff = runtime.handoffResume - if (hostHandoff === undefined) { - const template = config.resumeCommand - const fallback = template?.replaceAll('{session}', checked.record.header.id) - await overlay.close() - resumeOverlay = undefined - appendNotice(fallback === undefined - ? 'Session is resumable, but this host cannot hand it off in place.' - : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') - return - } - /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ - if (disposed) return - await ctx.sessions.flush(agent.session) - // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (disposed) return - if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) - await overlay.close() - resumeOverlay = undefined - await runtime.terminal.drainInput(100, 20) - // Disposal can run while terminal draining is pending; TypeScript does not model that reentry. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (disposed) return - ui.stop() - terminalReleased = true - await hostHandoff(checked.record.header.id) - throw new Error('resume host returned without replacing the process') - } catch (error: unknown) { - if (!disposed) { - if (terminalReleased) { - ui.start() - ui.setFocus(editor) - appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') - } else { - await overlay.close() - resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(error)}`, 'error') - } - } - } finally { - resumeInFlight = false - } - } - - /** Open the current-workspace searchable session selector. */ - const showResume = (): void => { - if (agent.status !== 'idle') { - appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') - return - } - if (sessionQuery === undefined) { - appendNotice('Resume is not available: session query is not mounted.', 'warning') - return - } - const scan = ++resumeScan - void resumeOverlay?.close() - void sessionQuery.listSessions().then(async (records) => { - if (isDisposed() || scan !== resumeScan) return - const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) - const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) - candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt - || a.record.header.id.localeCompare(b.record.header.id)) - if (isDisposed() || scan !== resumeScan) return - const session = overlayManager.open({ - create: host => new ResumePicker( - candidates, - resolved.maxResumeOptions, - runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), - () => host.viewport.rows, - palette, - (candidate) => { void handoffResume(candidate, session) }, - () => { void session.close() }, - ), - options: { - width: '100%', - maxHeight: '100%', - anchor: 'top-left', - margin: 0, - }, - }) - resumeOverlay = session - void session.closed.then(() => { - /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ - if (resumeOverlay === session) resumeOverlay = undefined - }) - requestRender() - }, (error: unknown) => { - if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') - }) - } - editor.onSubmit = (value: string) => { const text = value.trim() if (text === '') return @@ -1986,7 +1452,7 @@ export function createTuiChat( }, ) clearStatus() - disposeUserInteraction() + questions.unregister() ui.stop() throw error } diff --git a/packages/ui/tui/src/runtime.ts b/packages/ui/tui/src/runtime.ts new file mode 100644 index 0000000000..4345f656bf --- /dev/null +++ b/packages/ui/tui/src/runtime.ts @@ -0,0 +1,45 @@ +/** + * Host and process boundary the interactive TUI runs against: the resume-handoff + * host and the {@link TuiRuntime} the shipped CLI supplies (terminal, process + * exit, clock, and optional prompt/git overrides). These are plain interfaces so + * tests can drive the channel with a fake terminal. + * @module @deepseek-ai/dsh-tui/runtime + */ + +import type { Terminal } from '@earendil-works/pi-tui' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ +export interface TuiResumeHost { + /** + * Dispose the current app and replace it with a runtime for `sessionId`. + * Success does not return. A host may reject before it commits teardown; + * after commit it owns fatal reporting and process exit. + * @param sessionId - validated persisted session selected by the user. + */ + handoff(sessionId: SessionId): Promise +} + +/** 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 prompt'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 + /** + * Override the Git branch shown in the prompt context line; production resolves it once at mount. + * @param cwd - Operational working directory from the session header. + * @returns Unescaped branch name, or `undefined` outside a Git worktree. + */ + gitBranch?: (cwd: string) => string | undefined + /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ + now?(): number + /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ + handoffResume?: TuiResumeHost['handoff'] +} diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/ui/tui/tests/file-autocomplete.spec.ts index 53dd1f4f1a..18c7deba12 100644 --- a/packages/ui/tui/tests/file-autocomplete.spec.ts +++ b/packages/ui/tui/tests/file-autocomplete.spec.ts @@ -6,7 +6,7 @@ import { activeAtToken, formatFileMention, WorkspaceFileSearch, -} from '../src/file-autocomplete.ts' +} from '../src/chat/file-autocomplete.ts' const searches: WorkspaceFileSearch[] = [] const roots: string[] = [] diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 52015387a6..7aa8debf54 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -30,7 +30,7 @@ import { type TuiOverlaySession, type TuiRuntime, } from '../src/index.ts' -import { WorkspaceFileSearch } from '../src/file-autocomplete.ts' +import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts' import { appendAssistant, appendUser, diff --git a/packages/ui/tui/tests/xml-tool-output.spec.ts b/packages/ui/tui/tests/xml-tool-output.spec.ts index df270eed20..62fb475d73 100644 --- a/packages/ui/tui/tests/xml-tool-output.spec.ts +++ b/packages/ui/tui/tests/xml-tool-output.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { renderUnknownXml } from '../src/xml-tool-output.ts' +import { renderUnknownXml } from '../src/components/xml-tool-output.ts' const render = (source: string, limit = 4, expanded = false): string[] | undefined => renderUnknownXml( source,