Explorar o código

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.
Turtle hai 1 mes
pai
achega
46bbbce109

+ 6 - 0
.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

+ 36 - 0
.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.

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

+ 2 - 2
packages/ui/tui/src/autocomplete.ts → packages/ui/tui/src/chat/autocomplete.ts

@@ -1,7 +1,7 @@
 /**
 /**
  * Editor autocomplete provider merging path-only file candidates and optional
  * Editor autocomplete provider merging path-only file candidates and optional
  * session-reference snapshots with the base slash-command completions.
  * session-reference snapshots with the base slash-command completions.
- * @module @deepseek-ai/dsh-tui/autocomplete
+ * @module @deepseek-ai/dsh-tui/chat/autocomplete
  */
  */
 
 
 import {
 import {
@@ -15,7 +15,7 @@ import {
   formatSessionReferenceMention,
   formatSessionReferenceMention,
   type SessionReferenceService,
   type SessionReferenceService,
 } from '@deepseek-ai/dsh-session-reference'
 } 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'
 import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts'
 
 
 /** Merge path-only file candidates and optional session snapshots with commands. */
 /** Merge path-only file candidates and optional session snapshots with commands. */

+ 31 - 0
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
+}

+ 1 - 1
packages/ui/tui/src/file-autocomplete.ts → packages/ui/tui/src/chat/file-autocomplete.ts

@@ -3,7 +3,7 @@
  * paths only: selected values remain ordinary prompt text and file contents
  * paths only: selected values remain ordinary prompt text and file contents
  * stay behind the model-facing `read` tool.
  * 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'
 import { lstat, readdir } from 'node:fs/promises'

+ 151 - 0
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<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

+ 191 - 0
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<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
+    },
+  }
+}

+ 168 - 0
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<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,
+  }
+}

+ 245 - 0
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<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')
+      })
+    },
+  }
+}

+ 1 - 1
packages/ui/tui/src/skill-invocation.ts → packages/ui/tui/src/chat/skill-invocation.ts

@@ -1,7 +1,7 @@
 /**
 /**
  * Manual `/skill:<name> [instructions]` parsing and model-visible rendering for
  * Manual `/skill:<name> [instructions]` parsing and model-visible rendering for
  * the terminal front door.
  * 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'
 import { assertNever } from '@deepseek-ai/dsh-llm'

+ 1 - 1
packages/ui/tui/src/session/timing.ts → packages/ui/tui/src/chat/timing.ts

@@ -3,7 +3,7 @@
  * front door. Timing buckets are replayed from the session event stream; the
  * 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
  * running glyph fades in on turn start, throbs while the turn runs, and fades
  * out on turn end.
  * 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'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'

+ 1 - 1
packages/ui/tui/src/session/tokens.ts → packages/ui/tui/src/chat/tokens.ts

@@ -1,7 +1,7 @@
 /**
 /**
  * Running token accounting for the terminal footer. Usage is keyed per
  * Running token accounting for the terminal footer. Usage is keyed per
  * turn/step so replayed or re-emitted usage replaces rather than double-counts.
  * 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'
 import type { TokenUsage } from '@deepseek-ai/dsh-llm'

+ 2 - 2
packages/ui/tui/src/components/transcript.ts

@@ -25,7 +25,7 @@ import type {
   ToolResultView,
   ToolResultView,
 } from '@deepseek-ai/dsh-tools'
 } from '@deepseek-ai/dsh-tools'
 import type { FileDiff } 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 { displayInlineText, displayText } from './text.ts'
 import { gradientText, type Palette } from './theme.ts'
 import { gradientText, type Palette } from './theme.ts'
 import { contentText, type ParsedArguments } from './content.ts'
 import { contentText, type ParsedArguments } from './content.ts'
@@ -34,7 +34,7 @@ import {
   formatTimingTotals,
   formatTimingTotals,
   stepTimingAt,
   stepTimingAt,
   type StepPosition,
   type StepPosition,
-} from '../session/timing.ts'
+} from '../chat/timing.ts'
 
 
 /** Concatenate the text of every block of one type, separated by blank lines. */
 /** Concatenate the text of every block of one type, separated by blank lines. */
 function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
 function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {

+ 0 - 0
packages/ui/tui/src/xml-tool-output.ts → packages/ui/tui/src/components/xml-tool-output.ts


+ 1 - 1
packages/ui/tui/src/config.ts

@@ -10,7 +10,7 @@ import {
   DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
   DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
   DEFAULT_FILE_SEARCH_MAX_ENTRIES,
   DEFAULT_FILE_SEARCH_MAX_ENTRIES,
   DEFAULT_FILE_SEARCH_MAX_RESULTS,
   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. */
 /** Theme and prompt-template settings for the pi-tui terminal mode. */
 export interface TuiThemeConfig {
 export interface TuiThemeConfig {

+ 82 - 616
packages/ui/tui/src/index.ts

@@ -5,25 +5,18 @@
  * @module @deepseek-ai/dsh-tui
  * @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 {
 import {
   CombinedAutocompleteProvider,
   CombinedAutocompleteProvider,
   Container,
   Container,
-  CURSOR_MARKER,
-  Editor,
   Key,
   Key,
   Spacer,
   Spacer,
   Text,
   Text,
   TUI,
   TUI,
   ProcessTerminal,
   ProcessTerminal,
   matchesKey,
   matchesKey,
-  truncateToWidth,
   visibleWidth,
   visibleWidth,
   type EditorTheme,
   type EditorTheme,
   type SlashCommand,
   type SlashCommand,
-  type Terminal,
   type TerminalColorScheme,
   type TerminalColorScheme,
 } from '@earendil-works/pi-tui'
 } from '@earendil-works/pi-tui'
 import { Service, type Context, type Fiber } from 'cordis'
 import { Service, type Context, type Fiber } from 'cordis'
@@ -31,7 +24,6 @@ import {
   assembleContextFor,
   assembleContextFor,
   installAgentLlmTarget,
   installAgentLlmTarget,
   type Agent,
   type Agent,
-  type AgentLlmTarget,
   type AgentLlmTargetRef,
   type AgentLlmTargetRef,
   type AgentStatus,
   type AgentStatus,
   type HookContext,
   type HookContext,
@@ -40,36 +32,27 @@ import type {} from '@deepseek-ai/dsh-agent-loop'
 import type {} from '@deepseek-ai/dsh-token-meter'
 import type {} from '@deepseek-ai/dsh-token-meter'
 import type { CommandResult } from '@deepseek-ai/dsh-commands'
 import type { CommandResult } from '@deepseek-ai/dsh-commands'
 import { errorChain } from '@deepseek-ai/dsh-llm'
 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 type {} from '@deepseek-ai/dsh-llm-retry'
 import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
 import {
 import {
   displayPromptContent,
   displayPromptContent,
   SessionId,
   SessionId,
-  type Session,
   type SessionEvent,
   type SessionEvent,
-  type SessionHeader,
 } from '@deepseek-ai/dsh-session'
 } from '@deepseek-ai/dsh-session'
 import { foldGoal } from '@deepseek-ai/dsh-goal'
 import { foldGoal } from '@deepseek-ai/dsh-goal'
 import {
 import {
   parseSessionReferenceText,
   parseSessionReferenceText,
 } from '@deepseek-ai/dsh-session-reference'
 } from '@deepseek-ai/dsh-session-reference'
 import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
 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`
 // Type import also declaration-merges the optional `sessionPersistence`
 // service onto `Context` so `ctx.get('sessionPersistence')` is typed.
 // service onto `Context` so `ctx.get('sessionPersistence')` is typed.
 import type {} from '@deepseek-ai/dsh-session-persistence'
 import type {} from '@deepseek-ai/dsh-session-persistence'
 import type { SkillService } from '@deepseek-ai/dsh-skill'
 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 {
 import {
   TuiExtensionServiceImpl,
   TuiExtensionServiceImpl,
   TuiOverlayManager,
   TuiOverlayManager,
@@ -92,7 +75,7 @@ import {
   formatTokens,
   formatTokens,
   recordEventUsage,
   recordEventUsage,
   sessionTokens,
   sessionTokens,
-} from './session/tokens.ts'
+} from './chat/tokens.ts'
 import {
 import {
   fadeGlyph,
   fadeGlyph,
   formatQueuedStatus,
   formatQueuedStatus,
@@ -104,7 +87,7 @@ import {
   STATUS_FADE_MS,
   STATUS_FADE_MS,
   TIMING_BUCKET_GLYPHS,
   TIMING_BUCKET_GLYPHS,
   type StepPosition,
   type StepPosition,
-} from './session/timing.ts'
+} from './chat/timing.ts'
 import {
 import {
   resolveTuiConfig,
   resolveTuiConfig,
   type Config,
   type Config,
@@ -123,30 +106,40 @@ import {
   formatDiagnosticNumber,
   formatDiagnosticNumber,
   formatDiagnosticTime,
   formatDiagnosticTime,
   initialTarget,
   initialTarget,
-  ModelDialog,
-  QuestionDialog,
-  readModelChoices,
-  ResumePicker,
   StatusCardComponent,
   StatusCardComponent,
   PromptContextComponent,
   PromptContextComponent,
-  summarizeResumeCandidate,
   targetLabel,
   targetLabel,
-  targetReasoningLabel,
-  type ModelChoice,
-  type ModelDialogSelection,
-  type ResumeCandidate,
   type StatusCardRow,
   type StatusCardRow,
 } from './components/dialogs.ts'
 } from './components/dialogs.ts'
 import {
 import {
   parseSkillCommand,
   parseSkillCommand,
   renderSkillInvocation,
   renderSkillInvocation,
   SKILL_COMMAND_PREFIX,
   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 { 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 {
 export {
   resolveTuiConfig,
   resolveTuiConfig,
   TuiConfigSchema,
   TuiConfigSchema,
@@ -160,7 +153,7 @@ export {
   DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
   DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
   DEFAULT_FILE_SEARCH_MAX_ENTRIES,
   DEFAULT_FILE_SEARCH_MAX_ENTRIES,
   DEFAULT_FILE_SEARCH_MAX_RESULTS,
   DEFAULT_FILE_SEARCH_MAX_RESULTS,
-} from './file-autocomplete.ts'
+} from './chat/file-autocomplete.ts'
 
 
 export type {
 export type {
   TuiComponent,
   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.
  * 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. */
 /** 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.'
 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 {
 interface RunningStatus {
   turn: number | undefined
   turn: number | undefined
   timer: ReturnType<typeof setInterval>
   timer: ReturnType<typeof setInterval>
@@ -291,97 +227,12 @@ interface FadingStatus {
   timer: ReturnType<typeof setInterval>
   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. */
 /** Lifecycle handle for a mounted interactive terminal channel. */
 export interface TuiController {
 export interface TuiController {
   /** Stop rendering, restore the terminal, and reject pending questions. */
   /** Stop rendering, restore the terminal, and reject pending questions. */
   dispose(): Promise<void>
   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.
  * Start the interactive pi-tui channel for an already-created target agent.
  * @param ctx - agent, tools, session-event, and user-interaction context.
  * @param ctx - agent, tools, session-event, and user-interaction context.
@@ -453,22 +304,16 @@ export function createTuiChat(
   const toolCards = new Map<string, ToolCardComponent>()
   const toolCards = new Map<string, ToolCardComponent>()
   const allToolCards = new Set<ToolCardComponent>()
   const allToolCards = new Set<ToolCardComponent>()
   const liveErrors = new Set<string>()
   const liveErrors = new Set<string>()
-  const questionQueue: PendingQuestion[] = []
   const commandControllers = new Set<AbortController>()
   const commandControllers = new Set<AbortController>()
   const referenceControllers = 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
   let tuiServiceFiber: Fiber | undefined
   const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: 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 now = (): number => runtime.now?.() ?? Date.now()
   const agentStatus = (): AgentStatus => agent.status
   const agentStatus = (): AgentStatus => agent.status
   const isDisposed = (): boolean => disposed
   const isDisposed = (): boolean => disposed
@@ -507,6 +352,7 @@ export function createTuiChat(
     const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}`
     const usage = `↑${formatTokens(tokens.input)} ↓${formatTokens(tokens.output)}`
     modelValue.set(`  ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`)
     modelValue.set(`  ${palette.muted(displayText(target.current === undefined ? 'model unset' : compactTargetLabel(target.current)))}`)
     tokenValue.set(`  ${palette.muted(rate === undefined ? usage : `${usage}  cache ${rate}%`)}`)
     tokenValue.set(`  ${palette.muted(rate === undefined ? usage : `${usage}  cache ${rate}%`)}`)
+    const contextWindow = modelController.contextWindow()
     contextValue.set(contextWindow === undefined ? undefined : `  ${palette.muted(
     contextValue.set(contextWindow === undefined ? undefined : `  ${palette.muted(
       `${Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100))}% context`,
       `${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(' ')}`)
     indicatorValue.set(`${caret}${palette.muted(' ')}`)
   }
   }
-  updatePromptValues()
   const promptContext = new PromptContextComponent(
   const promptContext = new PromptContextComponent(
     parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)),
     parseTuiPromptTemplate(displayInlineText(resolved.theme.leftPrompt)),
     parseTuiPromptTemplate(displayInlineText(resolved.theme.rightPrompt)),
     parseTuiPromptTemplate(displayInlineText(resolved.theme.rightPrompt)),
@@ -622,130 +467,17 @@ export function createTuiChat(
 
 
   const disposeTargetListeners = installAgentLlmTarget(agent.ctx, target)
   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 => {
   const renderStatus = (): void => {
     streaming?.invalidate()
     streaming?.invalidate()
@@ -1062,147 +794,38 @@ export function createTuiChat(
     requestRender()
     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> => {
   const shutdown = (exitProcess: boolean): Promise<void> => {
     shuttingDown ??= (async () => {
     shuttingDown ??= (async () => {
       disposed = true
       disposed = true
       overlayManager.beginShutdown()
       overlayManager.beginShutdown()
-      contextResolution = undefined
+      modelController.resetContextResolution()
       clearStatus()
       clearStatus()
       for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
       for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
       commandControllers.clear()
       commandControllers.clear()
@@ -1210,19 +833,14 @@ export function createTuiChat(
       referenceControllers.clear()
       referenceControllers.clear()
       await tuiServiceFiber?.dispose()
       await tuiServiceFiber?.dispose()
       tuiServiceFiber = undefined
       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()
       await overlayManager.dispose()
-      modelOverlay = undefined
-      disposeUserInteraction()
+      modelController.clearOverlay()
+      questions.unregister()
       await runtime.terminal.drainInput(100, 20)
       await runtime.terminal.drainInput(100, 20)
       ui.stop()
       ui.stop()
       if (exitProcess) {
       if (exitProcess) {
-        const command = await currentResumeCommand()
+        const command = await resume.currentResumeCommand()
         if (command !== undefined) {
         if (command !== undefined) {
           runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`)
           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 latestActivity = events.at(-1)?.time ?? agent.session.header.createdAt
     const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens))
     const usedContext = Math.max(0, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens))
     let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown`
     let context = `${formatDiagnosticNumber(usedContext)} used · capacity unknown`
+    const contextWindow = modelController.contextWindow()
     if (contextWindow !== undefined) {
     if (contextWindow !== undefined) {
       const contextPercent = Math.round(usedContext / contextWindow * 100)
       const contextPercent = Math.round(usedContext / contextWindow * 100)
       context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})`
       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',
       description: 'Show or switch this session\'s model',
       input: { hint: '[[provider/]model]' },
       input: { hint: '[[provider/]model]' },
       handler: ({ rawInput }) => {
       handler: ({ rawInput }) => {
-        queueModelCommand(rawInput)
+        modelController.queueModelCommand(rawInput)
         return { kind: 'success' }
         return { kind: 'success' }
       },
       },
     })
     })
@@ -1469,7 +1088,7 @@ export function createTuiChat(
     commandCtx.commands.register({
     commandCtx.commands.register({
       name: 'resume',
       name: 'resume',
       description: 'List this workspace\'s resumable sessions',
       description: 'List this workspace\'s resumable sessions',
-      handler: () => { showResume(); return { kind: 'success' } },
+      handler: () => { resume.showResume(); return { kind: 'success' } },
     })
     })
     commandCtx.commands.register({
     commandCtx.commands.register({
       name: 'status',
       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) => {
   editor.onSubmit = (value: string) => {
     const text = value.trim()
     const text = value.trim()
     if (text === '') return
     if (text === '') return
@@ -1986,7 +1452,7 @@ export function createTuiChat(
       },
       },
     )
     )
     clearStatus()
     clearStatus()
-    disposeUserInteraction()
+    questions.unregister()
     ui.stop()
     ui.stop()
     throw error
     throw error
   }
   }

+ 45 - 0
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<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']
+}

+ 1 - 1
packages/ui/tui/tests/file-autocomplete.spec.ts

@@ -6,7 +6,7 @@ import {
   activeAtToken,
   activeAtToken,
   formatFileMention,
   formatFileMention,
   WorkspaceFileSearch,
   WorkspaceFileSearch,
-} from '../src/file-autocomplete.ts'
+} from '../src/chat/file-autocomplete.ts'
 
 
 const searches: WorkspaceFileSearch[] = []
 const searches: WorkspaceFileSearch[] = []
 const roots: string[] = []
 const roots: string[] = []

+ 1 - 1
packages/ui/tui/tests/tui.spec.ts

@@ -30,7 +30,7 @@ import {
   type TuiOverlaySession,
   type TuiOverlaySession,
   type TuiRuntime,
   type TuiRuntime,
 } from '../src/index.ts'
 } from '../src/index.ts'
-import { WorkspaceFileSearch } from '../src/file-autocomplete.ts'
+import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
 import {
 import {
   appendAssistant,
   appendAssistant,
   appendUser,
   appendUser,

+ 1 - 1
packages/ui/tui/tests/xml-tool-output.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
 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(
 const render = (source: string, limit = 4, expanded = false): string[] | undefined => renderUnknownXml(
   source,
   source,