refactor(tui): split createTuiChat into chat/ sub-controllers

Extract model-command, questions, and resume sub-machines from the
~1600-line createTuiChat closure into src/chat/ factories that take
explicit dependency bundles (shared ChatChannelDeps/ChannelNotice).
Reorganize src/ so chat/ holds all chat-channel concerns (former input
and session/ files move under it); xml-tool-output moves to components/;
TuiRuntime/TuiResumeHost move to runtime.ts. index.ts drops 2067->~1530
lines. Behavior identical: 167 tests and all TUI snapshots pass unchanged.
This commit is contained in:
Turtle
2026-07-27 20:49:44 +08:00
parent 84fe617a01
commit 46bbbce109
21 changed files with 1003 additions and 628 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 快照全部无需重录即通过,这正是本次重构的契约。

View File

@@ -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. */

View File

@@ -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
}

View File

@@ -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'

View File

@@ -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<number> {
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<number>): Set<string> {
const ids = new Set<string>()
for (const event of session.events) {
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
for (const block of event.data.content) {
if (block.type === 'tool-call') ids.add(block.id)
}
}
return ids
}
/**
* 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<string, unknown>
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<string, unknown>
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<SessionEvent, { type: 'user/message' | 'steering/message' }>,
): 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

View File

@@ -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<ContextResolution> | undefined
let modelOverlay: TuiOverlaySession | undefined
let modelCommands = Promise.resolve()
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
contextWindow = undefined
const resolution: Promise<ContextResolution> = 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<void> => {
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 <provider>/<model>.`, 'warning')
return
}
const selected = matches[0]
/* v8 ignore next -- a non-empty matches array always has index zero. */
if (selected === undefined) return
selectModel(selected)
}
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
},
}
}

View File

@@ -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<AskUserQuestionAnswer>((resolveAnswer, reject) => {
const pending: PendingQuestion = {
request,
index: 0,
answers: [],
resolve: resolveAnswer,
reject,
overlay: undefined,
onAbort: () => {
if (activeQuestion === pending) {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
return
}
// A non-active pending ask remains in the queue until this listener settles it.
questionQueue.splice(questionQueue.indexOf(pending), 1)
rejectQuestion(pending)
},
}
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
questionQueue.push(pending)
startNextQuestion()
})
},
})
return {
rejectAll(): void {
if (activeQuestion !== undefined) {
const pending = activeQuestion
activeQuestion = undefined
rejectQuestion(pending)
}
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
},
unregister,
}
}

View File

@@ -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<string | undefined>
}
/**
* 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<SessionHeader[]> => {
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<string>,
): Promise<ResumeCandidate> => {
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<ResumeCandidate> => {
/* 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<void> => {
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<string | undefined> => {
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')
})
},
}
}

View File

@@ -1,7 +1,7 @@
/**
* Manual `/skill:<name> [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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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<never>
}
/**
* 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<typeof setInterval>
@@ -291,97 +227,12 @@ interface FadingStatus {
timer: ReturnType<typeof setInterval>
}
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<void>
}
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<number> {
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<string, unknown>
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<string, unknown>
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<SessionEvent, { type: 'user/message' | 'steering/message' }>): string[][] {
return event.data.envelope?.prefixContexts.flatMap((context) => {
const card = sessionReferenceCard(context.meta)
return card === undefined ? [] : [card]
}) ?? []
}
function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
const ids = new Set<string>()
for (const event of session.events) {
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
for (const block of event.data.content) {
if (block.type === 'tool-call') ids.add(block.id)
}
}
return ids
}
/** 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<string, ToolCardComponent>()
const allToolCards = new Set<ToolCardComponent>()
const liveErrors = new Set<string>()
const questionQueue: PendingQuestion[] = []
const commandControllers = new Set<AbortController>()
const referenceControllers = new Set<AbortController>()
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<void> => {
const choices = await readModelChoices(ctx, target.current)
if (disposed) return
const argument = raw.trim()
if (argument === '') {
showModelSelector(choices)
return
}
const parts = argument.split(/\s+/u)
if (parts.length > 2) {
appendNotice('Usage: /model [provider/]model', 'warning')
return
}
let matches: ModelChoice[]
if (parts.length === 2) {
matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
} else {
const value = argument
const qualified = choices.filter(choice => targetLabel(choice) === value)
matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
}
if (matches.length === 0) {
appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
return
}
if (matches.length > 1) {
appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
return
}
const selected = matches[0]
/* v8 ignore next -- a non-empty matches array always has index zero. */
if (selected === undefined) return
selectModel(selected)
}
const queueModelCommand = (raw: string): void => {
modelCommands = modelCommands.then(async () => {
await handleModelCommand(raw)
}).catch((error: unknown) => {
if (!disposed) appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
})
}
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<AskUserQuestionAnswer>((resolveAnswer, reject) => {
const pending: PendingQuestion = {
request,
index: 0,
answers: [],
resolve: resolveAnswer,
reject,
overlay: undefined,
onAbort: () => {
if (activeQuestion === pending) {
activeQuestion = undefined
rejectQuestion(pending)
startNextQuestion()
return
}
// A non-active pending ask remains in the queue until this listener settles it.
questionQueue.splice(questionQueue.indexOf(pending), 1)
rejectQuestion(pending)
},
}
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
questionQueue.push(pending)
startNextQuestion()
})
},
const 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<SessionHeader[]> => {
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<string | undefined> => {
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<void> => {
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<string>,
): Promise<ResumeCandidate> => {
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<ResumeCandidate> => {
/* 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<void> => {
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
}

View File

@@ -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<never>
}
/** 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']
}

View File

@@ -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[] = []

View File

@@ -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,

View File

@@ -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,