/**
 * Interactive pi-tui front door for DeepSeek Harness agents. It renders the
 * durable session transcript, drives one configured agent, and provides
 * keyboard-driven user-interaction dialogs without owning agent lifecycle.
 * @module @deepseek-ai/dsh-tui
 */

import { homedir } from 'node:os'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import {
  CombinedAutocompleteProvider,
  Container,
  Editor,
  Input,
  Key,
  Loader,
  Markdown,
  Spacer,
  Text,
  TUI,
  ProcessTerminal,
  SelectList,
  matchesKey,
  truncateToWidth,
  visibleWidth,
  wrapTextWithAnsi,
  type Component,
  type AutocompleteItem,
  type AutocompleteProvider,
  type AutocompleteSuggestions,
  type EditorTheme,
  type Focusable,
  type MarkdownTheme,
  type SelectItem,
  type SelectListTheme,
  type SlashCommand,
  type Terminal,
  type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import { Service, type Context, type Fiber } from 'cordis'
import z from 'schemastery'
import {
  installAgentLlmTarget,
  type Agent,
  type AgentLlmTarget,
  type AgentLlmTargetRef,
  type AgentStatus,
  type HookContext,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-token-meter'
import type {} from '@deepseek-ai/dsh-commands'
import { assertNever, errorChain } from '@deepseek-ai/dsh-llm'
import type {
  ContentBlock,
  LlmModelInfo,
  LlmModelReasoningInfo,
  ReasoningEffortId,
  StreamChunk,
  TokenUsage,
} from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
  displayPromptContent,
  SessionId,
  type JsonValue,
  type Session,
  type SessionEvent,
  type SessionHeader,
  type TodoItem,
} from '@deepseek-ai/dsh-session'
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
import {
  formatSessionReferenceMention,
  parseSessionReferenceText,
  type SessionReferenceService,
} 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 { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill'
import type {
  FileDiff,
  TerminalCallView,
  ToolCallView,
  ToolDefinition,
  ToolResultView,
} from '@deepseek-ai/dsh-tools'
import {
  UserInteractionError,
  type AskUserQuestionAnswer,
  type AskUserQuestionAnswerItem,
  type AskUserQuestionItem,
  type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import {
  TuiExtensionServiceImpl,
  TuiOverlayManager,
} from './overlay-manager.ts'
import type {
  TuiOverlayRequest,
  TuiOverlaySession,
  TuiTheme,
} from './extension.ts'

export type {
  TuiComponent,
  TuiFocusable,
  TuiOverlayAnchor,
  TuiOverlayCloseReason,
  TuiOverlayHost,
  TuiOverlayMargin,
  TuiOverlayOptions,
  TuiOverlayOutcome,
  TuiOverlayRequest,
  TuiOverlaySession,
  TuiOverlayState,
  TuiTheme,
  TuiViewport,
} from './extension.ts'

declare module 'cordis' {
  interface Context {
    /** Terminal-only interaction service, available only while a TUI is mounted. */
    tui: TuiExtensionService
    /** Optional process host that can replace this TUI with a resumed session. */
    tuiResumeHost: TuiResumeHost
  }
}

/** 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.
 *
 * The concrete provider retains pi-tui, focus, and terminal lifecycle state.
 * Plugins receive only effect-owned overlay sessions.
 */
export abstract class TuiExtensionService extends Service {
  /** Exact agent driven by this terminal instance. */
  abstract readonly agent: Agent

  /**
   * Queue an interactive overlay owned by the calling plugin fiber.
   *
   * The TUI displays one overlay at a time in FIFO order. Disposing the caller
   * removes a queued overlay or closes an active one before plugin teardown
   * settles. This live presentation is neither logged nor replayed.
   *
   * @param request - component factory, layout constraints, and cancellation.
   * @returns the effect-owned overlay session.
   * @throws when the TUI has begun shutting down.
   */
  abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
}
import {
  activeAtToken,
  DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
  DEFAULT_FILE_SEARCH_MAX_ENTRIES,
  DEFAULT_FILE_SEARCH_MAX_RESULTS,
  formatFileMention,
  WorkspaceFileSearch,
} from './file-autocomplete.ts'

export {
  DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
  DEFAULT_FILE_SEARCH_MAX_ENTRIES,
  DEFAULT_FILE_SEARCH_MAX_RESULTS,
} from './file-autocomplete.ts'

export const name = 'ui-tui'
export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']

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

/** Interaction and presentation settings for the pi-tui terminal mode. */
export interface TuiConfig {
  /** Render model reasoning blocks. */
  showReasoning?: boolean
  /** Maximum tool-card body lines retained in its collapsed head/tail preview. */
  maxToolOutputLines?: number
  /** Maximum options visible at once in a user-question panel. */
  maxQuestionOptions?: number
  /** Maximum models visible at once in the model selector. */
  maxModelOptions?: number
  /** Maximum sessions visible at once in the resume selector. */
  maxResumeOptions?: number
  /** User-question panel width in terminal columns, clamped to the terminal. */
  questionDialogWidth?: number
  /** User-question panel maximum height in terminal rows. */
  questionDialogMaxHeight?: number
  /** Model-selector width in terminal columns. */
  modelDialogWidth?: number
  /** Model-selector maximum height in terminal rows. */
  modelDialogMaxHeight?: number
  /** Maximum fuzzy file candidates displayed for one `@` query. */
  fileSearchMaxResults?: number
  /** Maximum paths retained in one `@` workspace index. */
  fileSearchMaxEntries?: number
  /** Directory basenames excluded from `@` traversal and completion. */
  fileSearchExcludedDirectories?: string[]
  /** Show the terminal's hardware cursor at the pi editor's IME marker. */
  showHardwareCursor?: boolean
  /** Apply the built-in ANSI color palette. */
  color?: boolean
  /**
   * Paint the startup banner's product name in the DeepSeek brand gradient
   * using 24-bit truecolor. Requires {@link TuiConfig.color}; falls back to the
   * flat accent color when either is off. Unset auto-detects `COLORTERM` at the
   * process boundary, so most deployments leave it unset.
   */
  truecolor?: boolean
  /** Terminal window title while the UI is mounted; a logged session title prefixes it. */
  title?: string
}

const showReasoningSchema = z.boolean().default(true)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
const showHardwareCursorSchema = z.boolean().default(false)
const colorSchema = z.boolean().default(true)
// No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
const truecolorSchema = z.boolean()
const titleSchema = z.string().default('DeepSeek Harness')

const tuiConfigSchemaFields = {
  showReasoning: showReasoningSchema,
  maxToolOutputLines: maxToolOutputLinesSchema,
  maxQuestionOptions: maxQuestionOptionsSchema,
  maxModelOptions: maxModelOptionsSchema,
  maxResumeOptions: maxResumeOptionsSchema,
  questionDialogWidth: questionDialogWidthSchema,
  questionDialogMaxHeight: questionDialogMaxHeightSchema,
  modelDialogWidth: modelDialogWidthSchema,
  modelDialogMaxHeight: modelDialogMaxHeightSchema,
  fileSearchMaxResults: fileSearchMaxResultsSchema,
  fileSearchMaxEntries: fileSearchMaxEntriesSchema,
  fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
  showHardwareCursor: showHardwareCursorSchema,
  color: colorSchema,
  truecolor: truecolorSchema,
  title: titleSchema,
}

/** Schemastery schema for presentation settings embedded by app bundles. */
export const TuiConfigSchema: z<TuiConfig> = z.object(tuiConfigSchemaFields)

/** Serializable plugin configuration. */
export interface Config extends TuiConfig {
  /** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */
  welcome?: string
  /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
  sessionId?: string
  /**
   * Shell command fallback printed on exit or after selecting a session when
   * the host cannot hand off in place. Every `{session}` becomes the selected
   * id; the TUI never executes this text. Absent disables only the fallback,
   * not the interactive selector.
   */
  resumeCommand?: string
}

export const Config: z<Config> = z.object({
  welcome: z.string(),
  sessionId: z.string().default('main'),
  resumeCommand: z.string(),
  showReasoning: tuiConfigSchemaFields.showReasoning,
  maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
  maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
  maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
  maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
  questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
  questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
  modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
  modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
  fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
  fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
  fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
  showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor,
  color: tuiConfigSchemaFields.color,
  truecolor: tuiConfigSchemaFields.truecolor,
  title: tuiConfigSchemaFields.title,
})

/** Fully defaulted TUI presentation settings. */
export interface ResolvedTuiConfig {
  showReasoning: boolean
  maxToolOutputLines: number
  maxQuestionOptions: number
  maxModelOptions: number
  maxResumeOptions: number
  questionDialogWidth: number
  questionDialogMaxHeight: number
  modelDialogWidth: number
  modelDialogMaxHeight: number
  fileSearchMaxResults: number
  fileSearchMaxEntries: number
  fileSearchExcludedDirectories: string[]
  showHardwareCursor: boolean
  color: boolean
  truecolor: boolean
  title: string
}

/** Runtime boundary used by the interactive TUI. */
export interface TuiRuntime {
  /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
  terminal: Terminal
  /** Exit hook used by terminal shutdown or a target-agent startup failure. */
  exit(code: number): void
  /**
   * Override the footer's logical working-directory label without changing the session directory used by tools.
   * @param cwd - Operational working directory from the session header.
   * @returns Unescaped label; the TUI makes terminal controls visible.
   */
  formatCwd?: (cwd: string | undefined) => string
  /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
  now?(): number
  /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */
  handoffResume?: TuiResumeHost['handoff']
}

/**
 * Apply direct-call defaults after Loader schema validation has normally run.
 *
 * @param config - Deployment-provided terminal presentation settings.
 * @returns Complete settings consumed by the TUI renderer.
 */
export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
  return {
    showReasoning: config?.showReasoning ?? true,
    maxToolOutputLines: config?.maxToolOutputLines ?? 6,
    maxQuestionOptions: config?.maxQuestionOptions ?? 8,
    maxModelOptions: config?.maxModelOptions ?? 8,
    maxResumeOptions: config?.maxResumeOptions ?? 8,
    questionDialogWidth: config?.questionDialogWidth ?? 200,
    questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
    modelDialogWidth: config?.modelDialogWidth ?? 76,
    modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
    fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
    fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
    fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
    showHardwareCursor: config?.showHardwareCursor ?? false,
    color: config?.color ?? true,
    truecolor: config?.truecolor ?? false,
    title: config?.title ?? 'DeepSeek Harness',
  }
}

interface Palette {
  accent: (text: string) => string
  accent2: (text: string) => string
  text: (text: string) => string
  muted: (text: string) => string
  dim: (text: string) => string
  success: (text: string) => string
  warning: (text: string) => string
  error: (text: string) => string
  code: (text: string) => string
  added: (text: string) => string
  removed: (text: string) => string
  bold: (text: string) => string
  italic: (text: string) => string
  underline: (text: string) => string
  strike: (text: string) => string
  /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
  selected: (text: string) => string
}

function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
  return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
}

const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu
const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu
const BRACKETED_PASTE_START = '\u001B[200~'
const BRACKETED_PASTE_END = '\u001B[201~'

/**
 * Escape external C0/C1 controls before pi-tui adds application-owned ANSI.
 * Line feeds remain structural so transcript and tool output retain their layout.
 */
function displayText(text: string): string {
  return text.replace(TERMINAL_CONTROL_PATTERN, control =>
    `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
}

/** Escape external controls for terminal fields that must remain on one line. */
function displayInlineText(text: string): string {
  return displayText(text).replaceAll('\n', '\\x0a')
}

/** Remove terminal controls from clipboard text before an editable field stores it. */
function sanitizePastedText(text: string): string {
  return text
    .replace(TERMINAL_OSC_PATTERN, '')
    .replace(TERMINAL_CSI_PATTERN, '')
    .replace(TERMINAL_ESCAPE_PATTERN, '')
    .replace(TERMINAL_CONTROL_PATTERN, '')
}

/**
 * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
 * attributes, which every terminal remaps to its active color scheme. Body
 * `text` stays the terminal's default foreground so it reads on light and dark
 * backgrounds alike; grouping uses foreground-only gutter bars and reverse
 * video rather than fixed background fills.
 */
function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
  return {
    accent: ansi('94', '39', enabled),
    accent2: ansi('95', '39', enabled),
    text: text => text,
    muted: ansi('90', '39', enabled),
    // SGR 2 (dim) lightens text on a light background — substitute ANSI 90
    // (bright black / gray) which renders as a readable muted tone on any scheme.
    dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
    success: ansi('32', '39', enabled),
    warning: ansi('33', '39', enabled),
    error: ansi('31', '39', enabled),
    // ANSI 36 (cyan) is difficult to read on a light background — use
    // ANSI 34 (blue) which is legible on both light and dark schemes.
    code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
    added: ansi('32', '39', enabled),
    removed: ansi('31', '39', enabled),
    bold: ansi('1', '22', enabled),
    italic: ansi('3', '23', enabled),
    underline: ansi('4', '24', enabled),
    strike: ansi('9', '29', enabled),
    selected: ansi('7', '27', enabled),
  }
}

/**
 * DeepSeek brand gradient stops (indigo → light blue) taken from the
 * deepseek.com logo, painted across the startup banner's product name on
 * truecolor terminals. Fixed brand identity, deliberately outside the
 * theme-adaptive {@link Palette}.
 */
const BRAND_GRADIENT = [
  [77, 107, 254], // #4D6BFE
  [57, 130, 255], // #3982FF
  [36, 152, 255], // #2498FF
] as const

/**
 * Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
 * interpolation across its stops.
 *
 * @param t - Position along the gradient; clamped to [0, 1].
 * @returns The interpolated `[r, g, b]` channels, each rounded to 0–255.
 */
function brandColorAt(t: number): readonly [number, number, number] {
  const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1)
  const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2)
  const local = span - index
  // `index` is clamped to a valid adjacent pair, so both lookups are in-bounds.
  const from = BRAND_GRADIENT[index] as readonly [number, number, number]
  const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number]
  return [
    Math.round(from[0] + (to[0] - from[0]) * local),
    Math.round(from[1] + (to[1] - from[1]) * local),
    Math.round(from[2] + (to[2] - from[2]) * local),
  ]
}

/**
 * Paint `text` left-to-right in the DeepSeek brand gradient with per-character
 * 24-bit foreground codes, resetting to the default foreground at the end.
 * Foreground-only, so it stays legible on any terminal background; the caller
 * gates it on truecolor support and wraps it in bold.
 *
 * @param text - Text to colorize; sampled once per character.
 * @returns `text` wrapped in truecolor SGR foreground codes.
 */
function gradientText(text: string): string {
  // The sole caller passes the ASCII product name, so UTF-16 unit iteration
  // samples exactly one color per visible letter.
  const last = Math.max(1, text.length - 1)
  let painted = ''
  for (let index = 0; index < text.length; index += 1) {
    const [r, g, b] = brandColorAt(index / last)
    painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
  }
  return `${painted}\x1b[39m`
}

function markdownTheme(palette: Palette): MarkdownTheme {
  return {
    heading: text => palette.accent(text),
    link: text => palette.accent(text),
    // pi-tui requires this URL slot but its current Markdown renderer does not invoke it.
    /* v8 ignore next */
    linkUrl: text => palette.dim(text),
    code: text => palette.code(text),
    codeBlock: text => palette.text(text),
    codeBlockBorder: text => palette.dim(text),
    quote: text => palette.muted(text),
    quoteBorder: text => palette.accent2(text),
    hr: text => palette.dim(text),
    listBullet: text => palette.accent(text),
    bold: text => palette.bold(text),
    italic: text => palette.italic(text),
    strikethrough: text => palette.strike(text),
    underline: text => palette.underline(text),
  }
}

function selectTheme(palette: Palette): SelectListTheme {
  return {
    selectedPrefix: palette.accent,
    selectedText: palette.accent,
    description: palette.muted,
    scrollInfo: palette.dim,
    noMatch: palette.warning,
  }
}

function dialogSelectTheme(palette: Palette): SelectListTheme {
  return {
    ...selectTheme(palette),
    selectedText: text => palette.selected(palette.accent(text)),
  }
}

function contentText(content: readonly ContentBlock[]): string {
  const parts: string[] = []
  for (const block of content) {
    switch (block.type) {
      case 'text':
      case 'reasoning':
        parts.push(block.text)
        break
      case 'tool-call':
        parts.push(`${block.name}(${block.arguments})`)
        break
      case 'tool-result':
        parts.push(contentText(block.content))
        break
      default: {
        const rawType = (block as { type?: unknown }).type
        parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`)
        break
      }
    }
  }
  return parts.join('')
}

function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
  return content
    .filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
    .map(block => block.text)
    .join('\n\n')
}

interface ModelChoice extends AgentLlmTarget {
  modelName: string
  description?: string
  reasoning?: LlmModelReasoningInfo
}

interface ModelDialogSelection {
  choice: ModelChoice
  reasoningEffort: ReasoningEffortId | undefined
}

function targetLabel(target: AgentLlmTarget): string {
  return `${target.provider}/${target.model}`
}

function compactTargetLabel(target: AgentLlmTarget): string {
  return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}`
}

function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined {
  if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default'
  return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort
}

function initialTarget(agent: Agent): AgentLlmTarget | undefined {
  const logged = agent.session.requestHeader()?.config
  if (logged !== undefined) {
    if (logged.reasoningEffort === undefined) {
      return { provider: logged.provider, model: logged.model }
    }
    return {
      provider: logged.provider,
      model: logged.model,
      reasoningEffort: logged.reasoningEffort,
    }
  }
  if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
  return { provider: agent.options.provider, model: agent.options.model }
}

async function readModelChoices(
  ctx: Context,
  current: AgentLlmTarget | undefined,
): Promise<ModelChoice[]> {
  const providers = ctx.llm.listProviders()
  const groups = await Promise.all(providers.map(async (provider) => {
    const advertised = await ctx.llm.listModels(provider.id)
    const models: LlmModelInfo[] = [...advertised]
    if (
      current?.provider === provider.id
      && !models.some(model => model.id === current.model)
    ) {
      models.push({ provider: provider.id, id: current.model, name: current.model })
    }
    return Promise.all(models.map(async (model): Promise<ModelChoice> => {
      const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning
      return {
        provider: provider.id,
        model: model.id,
        modelName: model.name,
        ...model.description === undefined ? {} : { description: model.description },
        ...reasoning === undefined ? {} : { reasoning },
      }
    }))
  }))
  return groups.flat()
}

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

/**
 * Borderless startup banner: product title, an optional configured subtitle,
 * and the model/session detail line. No box frame — each line renders as plain
 * left-padded text (matching transcript notices) so it reads on any theme.
 */
class HeaderComponent implements Component {
  /** Columns of the banner currently revealed; `undefined` renders it whole. */
  private revealWidth: number | undefined

  constructor(
    private readonly agent: Agent,
    private readonly subtitle: () => string | undefined,
    private readonly palette: Palette,
    private readonly gradient: boolean,
    private readonly currentModel: () => string | undefined,
  ) {}

  /** Clip the banner to `width` columns (the sweep reveal); `undefined` restores it. */
  setRevealWidth(width: number | undefined): void {
    this.revealWidth = width
  }

  invalidate(): void {}

  render(width: number): string[] {
    const usable = Math.max(1, width - 2)
    const name = this.gradient
      ? this.palette.bold(gradientText('DEEPSEEK'))
      : this.palette.bold(this.palette.accent('DEEPSEEK'))
    const title = `${name} ${this.palette.bold('HARNESS')}`
    const model = displayText(this.currentModel() ?? 'model unset')
    const detail = `${model}  •  ${displayText(this.agent.session.id)}`
    const subtitle = this.subtitle()
    const lines = [
      title,
      ...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
      this.palette.dim(detail),
    ]
      .flatMap(line => wrapTextWithAnsi(line, usable))
      .map(line => ` ${truncateToWidth(line, usable, '')}`)
    if (this.revealWidth === undefined) return lines
    const revealed = this.revealWidth
    return lines.map(line => truncateToWidth(line, revealed, ''))
  }
}

/** Milliseconds between elapsed-time refreshes of the running status line. */
const STATUS_ELAPSED_INTERVAL_MS = 1000

/** Steering/cancel affordance shown on every running status line. */
const STATUS_HINT = 'Enter sends steering, Esc cancels'

/**
 * Fine-grained activity of a running turn, derived in the TUI from session
 * lifecycle events for the status line. It is presentation-only, not a durable
 * agent state: `waiting` spans a step from its `step/start` until the first
 * reasoning or text chunk, `thinking`/`responding` track reasoning/text deltas,
 * and `executing` covers tool calls until the next step begins.
 */
type TurnPhase = 'waiting' | 'thinking' | 'responding' | 'executing'

/**
 * Live controller for the running status line: its {@link Loader}, the derived
 * {@link TurnPhase}, the elapsed-time baselines the label reads, and the timer
 * that refreshes it. Present only while the turn runs; `undefined` when idle.
 */
interface RunningStatus {
  loader: Loader
  phase: TurnPhase
  phaseStartedAt: number
  stepStartedAt: number
  timer: ReturnType<typeof setInterval>
}

/** Status-line label for each {@link TurnPhase}. */
const TURN_PHASE_LABELS: Record<TurnPhase, string> = {
  waiting: 'Waiting for the first token',
  thinking: 'Thinking',
  responding: 'Responding',
  executing: 'Executing tools',
}

/**
 * Format a non-negative elapsed span as a compact status duration: whole
 * seconds under a minute (`8s`), else minutes and zero-padded seconds
 * (`1m05s`).
 * @param elapsedMs - Elapsed time in milliseconds; negatives clamp to zero.
 * @returns The compact duration string.
 */
function formatStatusDuration(elapsedMs: number): string {
  const total = Math.floor(Math.max(0, elapsedMs) / 1000)
  if (total < 60) return `${total}s`
  return `${Math.floor(total / 60)}m${(total % 60).toString().padStart(2, '0')}s`
}

/**
 * Compose the running status-line text from the current phase, its timers, and
 * the queued-steering badge. The waiting phase spans the whole step so it shows
 * one duration; later phases show time in the phase plus the running step
 * total, and a non-zero `queued` count surfaces as a badge before the hint.
 * @param phase - The current turn phase.
 * @param phaseMs - Elapsed time in the current phase, in milliseconds.
 * @param stepMs - Elapsed time in the current step, in milliseconds.
 * @param queued - Count of pending steering messages; zero hides the badge.
 * @returns The status-line text, including the steering/cancel hint.
 */
function formatTurnStatus(phase: TurnPhase, phaseMs: number, stepMs: number, queued: number): string {
  const timing = phase === 'waiting'
    ? formatStatusDuration(stepMs)
    : `${formatStatusDuration(phaseMs)} · total ${formatStatusDuration(stepMs)}`
  const badge = queued > 0 ? `${queued} queued · ` : ''
  return `${TURN_PHASE_LABELS[phase]} ${timing} — ${badge}${STATUS_HINT}`
}

/**
 * Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so
 * it renders legibly on any terminal background — unlike a filled block whose
 * body text would collide with the theme's default foreground.
 */
class GutterBox implements Component {
  protected readonly children: Component[] = []

  constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {}

  addChild(child: Component): void {
    this.children.push(child)
  }

  invalidate(): void {
    for (const child of this.children) child.invalidate()
  }

  render(width: number): string[] {
    const inner = Math.max(1, width - 2)
    const body: string[] = []
    for (const child of this.children) for (const line of child.render(inner)) body.push(line)
    // Every caller adds a non-empty title/label child, so an all-empty box is unreachable;
    // the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars.
    /* v8 ignore next */
    if (body.length === 0) return []
    const bar = this.barFn('▌')
    const pad = Array.from({ length: this.paddingY }, () => '')
    return [...pad, ...body, ...pad].map(line => `${bar} ${line}`)
  }
}

class UserMessageComponent extends GutterBox {
  constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
    super(value => palette.accent(value))
    this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0))
    this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, {
      preserveOrderedListMarkers: true,
      preserveBackslashEscapes: true,
    }))
  }
}

class AssistantMessageComponent extends Container {
  constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) {
    super()
    const reasoning = displayText(textBlocks(content, 'reasoning').trim())
    const text = displayText(textBlocks(content, 'text').trim())
    if (reasoning && showReasoning) {
      this.addChild(new Spacer(1))
      this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0))
      this.addChild(new Markdown(reasoning, 1, 0, mdTheme, {
        color: value => palette.muted(value),
        italic: true,
      }))
    }
    if (text) {
      this.addChild(new Spacer(1))
      this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0))
      this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) }))
    }
  }
}

interface StreamingBlock {
  type: string
  text: string
}

class StreamingAssistantComponent extends Container {
  private readonly blocks = new Map<number, StreamingBlock>()

  constructor(
    private showReasoning: boolean,
    private readonly palette: Palette,
    private readonly mdTheme: MarkdownTheme,
  ) {
    super()
  }

  update(chunk: StreamChunk): void {
    if (chunk.type === 'block-start') {
      this.blocks.set(chunk.index, { type: chunk.blockType, text: '' })
    } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
      const type = chunk.type === 'text-delta' ? 'text' : 'reasoning'
      const block = this.blocks.get(chunk.index) ?? { type, text: '' }
      block.text += chunk.text
      this.blocks.set(chunk.index, block)
    } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) {
      this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text })
    }
    this.rebuild()
  }

  setShowReasoning(show: boolean): void {
    this.showReasoning = show
    this.rebuild()
  }

  private rebuild(): void {
    this.clear()
    const content: ContentBlock[] = [...this.blocks.entries()]
      .sort(([left], [right]) => left - right)
      .flatMap<ContentBlock>(([, block]) => {
        if (block.type === 'text') return [{ type: 'text', text: block.text }]
        if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
        return []
      })
    const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme)
    for (const child of component.children) this.addChild(child)
  }
}

interface ParsedArguments {
  value: unknown
  valid: boolean
}

function parseArguments(raw: string): ParsedArguments {
  try {
    return { value: JSON.parse(raw), valid: true }
  } catch {
    return { value: raw, valid: false }
  }
}

function pretty(value: unknown): string {
  if (typeof value === 'string') return displayText(value)
  // The lib declaration narrows `unknown` to a string-returning overload, but
  // JSON.stringify returns undefined for runtime values such as symbols.
  const serialized = JSON.stringify(value, null, 2) as string | undefined
  return displayText(serialized ?? String(value))
}

function diffLines(diff: FileDiff, palette: Palette): string[] {
  const lines = [palette.bold(displayText(diff.path))]
  if (diff.oldText !== null) {
    for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`))
  }
  for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
  return lines
}

class ToolCardComponent implements Component {
  private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
  private expanded = false
  private callView: ToolCallView
  private resultView: ToolResultView | undefined

  constructor(
    private readonly name: string,
    private readonly parsed: ParsedArguments,
    private readonly definition: ToolDefinition | undefined,
    private readonly maxOutputLines: number,
    private readonly palette: Palette,
  ) {
    this.callView = this.presentCall()
  }

  private presentCall(): ToolCallView {
    if (this.parsed.valid && this.definition?.presentCall) {
      try {
        const view = this.definition.presentCall(this.parsed.value)
        if (view !== undefined) return view
      } catch (error: unknown) {
        return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` }
      }
    }
    return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value }
  }

  updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
    this.result = {
      content: [...event.content],
      isError: event.isError,
      ...event.meta !== undefined ? { meta: event.meta } : {},
    }
    if (this.parsed.valid && this.definition?.presentResult) {
      try {
        const view = this.definition.presentResult(this.parsed.value, this.result)
        if (view !== undefined) this.resultView = view
      } catch (error: unknown) {
        this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] }
      }
    }
  }

  setExpanded(expanded: boolean): void {
    this.expanded = expanded
  }

  invalidate(): void {}

  render(width: number): string[] {
    const isError = this.result?.isError ?? false
    const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓')
    const body = this.renderBody()
    const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '')
    const headLines = Math.ceil(this.maxOutputLines / 2)
    const tailLines = this.maxOutputLines - headLines
    const visibleBody = this.expanded || body.length <= this.maxOutputLines
      ? body
      : [
        ...body.slice(0, headLines),
        this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
        ...body.slice(body.length - tailLines),
      ]
    const barFn = this.result === undefined
      ? this.palette.warning
      : isError ? this.palette.error : this.palette.success
    const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0)
    box.addChild(new Text(this.palette.bold(title), 0, 0))
    if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0))
    return box.render(width)
  }

  private title(): string {
    return this.resultView?.title ?? this.callView.title
  }

  private renderBody(): string[] {
    const view = this.resultView ?? this.callView
    if (view.card === 'terminal') {
      const pending = this.callView.card === 'terminal' ? this.callView : undefined
      const lines: string[] = []
      if (pending?.description) lines.push(this.palette.muted(displayText(pending.description)))
      if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd)))
      if (this.resultView?.card === 'terminal') {
        if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n'))
        if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
        if (this.resultView.signal !== undefined) {
          lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`))
        }
      } else if (this.result === undefined) {
        // A pending terminal view is the call view itself; TerminalCallView requires a title.
        lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`))
      } else {
        lines.push(...displayText(contentText(this.result.content)).split('\n'))
      }
      return lines.filter(Boolean)
    }
    if (view.card === 'diff') {
      return view.diffs.flatMap((diff, index) => [
        ...index > 0 ? [''] : [],
        ...diffLines(diff, this.palette),
      ])
    }
    const content = view.content ?? this.result?.content
    const lines: string[] = []
    if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n'))
    const rawInput = this.result === undefined && this.callView.card === 'generic'
      ? this.callView.rawInput
      : undefined
    if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n'))
    return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1))
  }
}

class TodoComponent implements Component {
  private todos: readonly TodoItem[] = []

  constructor(private readonly palette: Palette) {}

  update(todos: readonly TodoItem[]): void {
    this.todos = todos
  }

  invalidate(): void {}

  render(width: number): string[] {
    if (this.todos.length === 0) return []
    const lines = [this.palette.bold(this.palette.accent('Plan'))]
    for (const todo of this.todos) {
      const prefix = todo.status === 'completed'
        ? this.palette.success('✓')
        : todo.status === 'in_progress'
          ? this.palette.warning('●')
          : this.palette.dim('○')
      const content = displayText(todo.content)
      const text = todo.status === 'completed' ? this.palette.muted(content) : content
      lines.push(truncateToWidth(`  ${prefix} ${text}`, width, ''))
    }
    return ['', ...lines]
  }
}

function formatTokens(value: number): string {
  if (value < 1_000) return String(value)
  if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`
  if (value < 1_000_000) return `${Math.round(value / 1_000)}k`
  return `${(value / 1_000_000).toFixed(1)}m`
}

function formatCwd(cwd: string | undefined): string {
  if (cwd === undefined) return 'cwd unset'
  const home = homedir()
  const rel = relative(resolve(home), resolve(cwd))
  if (rel === '') return '~'
  /* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
  if (isAbsolute(rel)) return cwd
  if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
  return cwd
}

/**
 * Running token totals for the footer, keyed per turn/step so replayed or
 * re-emitted usage replaces rather than double-counts; `input` is uncached
 * input, cache buckets are disjoint.
 */
interface SessionTokenTotals {
  input: number
  output: number
  cacheRead: number
  cacheWrite: number
  readonly byStep: Map<string, TokenUsage>
}

function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void {
  const key = `${turn}:${step}`
  const previous = totals.byStep.get(key)
  if (previous !== undefined) {
    totals.input -= previous.inputTokens
    totals.output -= previous.outputTokens
    totals.cacheRead -= previous.cacheReadTokens ?? 0
    totals.cacheWrite -= previous.cacheWriteTokens ?? 0
  }
  totals.byStep.set(key, usage)
  totals.input += usage.inputTokens
  totals.output += usage.outputTokens
  totals.cacheRead += usage.cacheReadTokens ?? 0
  totals.cacheWrite += usage.cacheWriteTokens ?? 0
}

function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void {
  if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
    recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage)
  } else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
    recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage)
  }
}

/**
 * Share of billed input (prompt) tokens served from the provider cache, as an
 * integer percent, or `undefined` before any input is billed (avoids 0/0 and a
 * meaningless rate on an empty session).
 */
function cacheHitRate(totals: SessionTokenTotals): number | undefined {
  const billedInput = totals.input + totals.cacheRead + totals.cacheWrite
  if (billedInput === 0) return undefined
  return Math.round((totals.cacheRead / billedInput) * 100)
}

function sessionTokens(session: Session): SessionTokenTotals {
  const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() }
  for (const event of session.events) {
    recordEventUsage(totals, event)
  }
  return totals
}

function formatDiagnosticNumber(value: number): string {
  return value.toLocaleString('en-US')
}

function formatDiagnosticTime(value: number): string {
  return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC')
}

function formatDiagnosticCount(value: number, singular: string): string {
  return `${String(value)} ${singular}${value === 1 ? '' : 's'}`
}

function diagnosticMeter(percent: number, palette: Palette): string {
  const width = 16
  const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width)
  return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}`
}

type StatusCardRow = readonly [label: string, value: string]

/** Bordered, grouped field card for one point-in-time status snapshot. */
class StatusCardComponent implements Component {
  constructor(
    private readonly groups: readonly (readonly StatusCardRow[])[],
    private readonly palette: Palette,
  ) {}

  invalidate(): void {}

  render(width: number): string[] {
    const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`))
    const naturalLabelWidth = Math.max(...labels.map(label => label.length))
    const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) =>
      1 + naturalLabelWidth + 2 + visibleWidth(value))))
    const cardWidth = Math.min(
      Math.max(8, width),
      Math.max('Session status'.length + 5, naturalBodyWidth + 4),
    )
    const innerWidth = Math.max(1, cardWidth - 4)
    const labelWidth = Math.min(
      naturalLabelWidth,
      Math.max(1, Math.floor(innerWidth / 3)),
    )
    const body: string[] = []
    for (const [groupIndex, group] of this.groups.entries()) {
      if (groupIndex > 0) body.push('')
      for (const [label, value] of group) {
        const plainLabel = truncateToWidth(`${label}:`, labelWidth, '')
        const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))}  `
        const continuation = ' '.repeat(1 + labelWidth + 2)
        const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix))
        const wrapped = wrapTextWithAnsi(value, valueWidth)
        for (const [lineIndex, line] of wrapped.entries()) {
          body.push(`${lineIndex === 0 ? prefix : continuation}${line}`)
        }
      }
    }

    const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '')
    const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5))
    const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}`
    const lines = [top]
    for (const line of body) {
      const clipped = truncateToWidth(line, innerWidth, '')
      lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`)
    }
    lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`))
    return lines
  }
}

class FooterComponent implements Component {
  constructor(
    private readonly agent: Agent,
    private readonly palette: Palette,
    private readonly toolsExpanded: () => boolean,
    private readonly tokens: () => SessionTokenTotals,
    private readonly cwdFormatter: TuiRuntime['formatCwd'],
    private readonly currentModel: () => string | undefined,
    private readonly contextPercent: () => number | undefined,
  ) {}

  invalidate(): void {}

  render(width: number): string[] {
    const totals = this.tokens()
    const model = displayText(this.currentModel() ?? 'model unset')
    const rate = cacheHitRate(totals)
    const cache = rate === undefined ? '' : `  cache ${rate}%`
    const formattedCwd = displayText(
      this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd),
    )
    const left = `${model}  ${formattedCwd}  ↑${formatTokens(totals.input)} ↓${formatTokens(totals.output)}${cache}`
    const contextPercent = this.contextPercent()
    const context = contextPercent === undefined ? '' : `${contextPercent}% context  `
    const right = `${context}tools:${this.toolsExpanded() ? 'expanded' : 'collapsed'}`
    const leftStyled = this.palette.dim(left)
    const available = Math.max(0, width - visibleWidth(left) - 2)
    const rightClipped = truncateToWidth(right, available, '')
    const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped)))
    return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')]
  }
}

interface QuestionSelection {
  selected: string[]
  custom?: string
}

function renderDialog(
  title: string,
  body: readonly string[],
  width: number,
  palette: Palette,
): string[] {
  const innerWidth = Math.max(1, width - 4)
  const topLabel = ` ${displayText(title)} `
  const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮`
  const lines: string[] = [palette.accent(top)]
  for (const line of body) {
    const clipped = truncateToWidth(line, innerWidth, '')
    lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`)
  }
  lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`))
  return lines
}

class ModelDialog implements Component {
  private readonly list: SelectList
  private readonly items: Map<string, SelectItem>
  private readonly choices: Map<string, ModelChoice>
  private readonly efforts: Map<string, ReasoningEffortId | undefined>
  private readonly currentValue: string | undefined

  constructor(
    choices: readonly ModelChoice[],
    current: AgentLlmTarget | undefined,
    maxVisible: number,
    private readonly palette: Palette,
    done: (selection: ModelDialogSelection) => void,
    cancel: () => void,
  ) {
    this.items = new Map()
    this.choices = new Map()
    this.efforts = new Map()
    this.currentValue = current === undefined ? undefined : targetLabel(current)
    for (const choice of choices) {
      const value = targetLabel(choice)
      const isCurrent = current?.provider === choice.provider && current.model === choice.model
      this.choices.set(value, choice)
      this.efforts.set(
        value,
        isCurrent
          ? current.reasoningEffort ?? choice.reasoning?.defaultEffort
          : choice.reasoning?.defaultEffort,
      )
      this.items.set(value, {
        value,
        label: displayText(value),
        description: this.describeChoice(choice, isCurrent),
      })
    }
    this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette))
    const currentIndex = current === undefined
      ? 0
      : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
    this.list.setSelectedIndex(currentIndex)
    this.list.onSelect = (item) => {
      const selected = choices.find(choice => targetLabel(choice) === item.value)
      /* v8 ignore next -- SelectList only returns values built from `choices`. */
      if (selected === undefined) return
      done({
        choice: selected,
        reasoningEffort: this.efforts.get(item.value),
      })
    }
    this.list.onCancel = cancel
  }

  private describeChoice(choice: ModelChoice, isCurrent: boolean): string {
    const selectedEffort = this.efforts.get(targetLabel(choice))
    const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort)
    const effortLabel = selectedEffort === undefined
      ? choice.reasoning === undefined ? undefined : 'provider default'
      : effort?.name ?? selectedEffort
    return [
      displayText(choice.modelName),
      ...choice.description === undefined ? [] : [displayText(choice.description)],
      ...effortLabel === undefined ? [] : [displayText(effortLabel)],
      ...isCurrent ? ['current'] : [],
    ].join(' — ')
  }

  private cycleReasoningEffort(): void {
    const selectedItem = this.list.getSelectedItem()
    /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */
    if (selectedItem === null) return
    const choice = this.choices.get(selectedItem.value)
    if (choice?.reasoning === undefined) return
    const current = this.efforts.get(selectedItem.value)
    const efforts: Array<ReasoningEffortId | undefined> = [
      ...choice.reasoning.defaultEffort === undefined ? [undefined] : [],
      ...choice.reasoning.efforts.map(effort => effort.id),
    ]
    const currentIndex = efforts.indexOf(current)
    const next = efforts[(currentIndex + 1) % efforts.length]
    this.efforts.set(selectedItem.value, next)
    const item = this.items.get(selectedItem.value)
    /* v8 ignore next -- items and choices are constructed from the same values. */
    if (item === undefined) return
    item.description = this.describeChoice(choice, selectedItem.value === this.currentValue)
  }

  invalidate(): void {
    this.list.invalidate()
  }

  handleInput(data: string): void {
    if (matchesKey(data, Key.shift(Key.tab))) {
      this.cycleReasoningEffort()
    } else {
      this.list.handleInput(data)
    }
    this.invalidate()
  }

  render(width: number): string[] {
    const innerWidth = Math.max(1, width - 4)
    return renderDialog('Select model', [
      ...this.list.render(innerWidth),
      '',
      this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
    ], width, this.palette)
  }
}

interface ResumeRoute {
  provider: string
  model: string
}

interface ResumeCandidate {
  record: SessionRecord
  title: string
  lastActivityAt: number
  lastTurn: string
  route?: ResumeRoute
  goalPhase?: GoalPhase
  disabledReason?: string
}

function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
  const event = snapshot.events.findLast(item => item.type === 'turn/end')
  if (event === undefined) return 'no completed turn'
  const reason = event.data.reason
  switch (reason.kind) {
    case 'completed': return `turn ${event.data.turn}: completed`
    case 'aborted': return `turn ${event.data.turn}: cancelled`
    case 'error': return `turn ${event.data.turn}: error`
    case 'disposed': return `turn ${event.data.turn}: disposed`
    case 'max-tokens': return `turn ${event.data.turn}: max tokens`
    case 'rejected': return `turn ${event.data.turn}: rejected`
    case 'interrupted': return `turn ${event.data.turn}: interrupted`
    default: return `turn ${event.data.turn}: unknown result`
  }
}

function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
  const header = snapshot.events.findLast(item => item.type === 'request/header')
  if (header?.type === 'request/header') {
    return { provider: header.data.header.config.provider, model: header.data.header.config.model }
  }
  const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
  return assistant?.type === 'assistant/message'
    ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model }
    : undefined
}

function summarizeResumeCandidate(
  record: SessionRecord,
  snapshot: SessionLogSnapshot,
  currentId: SessionId,
  cwd: string | undefined,
  availableProviders: ReadonlySet<string>,
): ResumeCandidate {
  const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
  const route = resumeRoute(snapshot)
  const foldedGoal = foldGoal(snapshot.events).goal
  let disabledReason: string | undefined
  if (record.header.id === currentId) disabledReason = 'current session'
  else if (record.live) disabledReason = 'session is already live in this runtime'
  else if (record.header.cwd !== cwd) disabledReason = 'different workspace'
  else if (route !== undefined && !availableProviders.has(route.provider)) {
    disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
  }
  return {
    record,
    title,
    lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
    lastTurn: resumeTurnLabel(snapshot),
    ...route === undefined ? {} : { route },
    ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
    ...disabledReason === undefined ? {} : { disabledReason },
  }
}

/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
class ResumePicker implements Component, Focusable {
  private readonly search = new Input()
  private pasteBuffer: string | undefined
  private selectedIndex = 0
  private error = ''
  focused = false

  constructor(
    private readonly candidates: readonly ResumeCandidate[],
    private readonly maxVisible: number,
    private readonly workspaceLabel: string,
    private readonly viewportRows: () => number,
    private readonly palette: Palette,
    private readonly done: (candidate: ResumeCandidate) => void,
    private readonly cancel: () => void,
  ) {}

  invalidate(): void {
    this.search.invalidate()
  }

  private filtered(): ResumeCandidate[] {
    const query = this.search.getValue().trim().toLocaleLowerCase()
    if (query === '') return [...this.candidates]
    return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
      || candidate.record.header.id.toLocaleLowerCase().includes(query))
  }

  private visibleCandidateCount(): number {
    const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
    return Math.min(this.maxVisible, candidateBudget)
  }

  private handleBracketedPaste(data: string): boolean {
    const start = data.indexOf(BRACKETED_PASTE_START)
    if (this.pasteBuffer === undefined && start < 0) return false
    if (this.pasteBuffer === undefined) {
      const prefix = data.slice(0, start)
      if (prefix !== '') this.handleInput(prefix)
      this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length)
    } else {
      this.pasteBuffer += data
    }
    const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END)
    if (end < 0) return true
    const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end))
    const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length)
    this.pasteBuffer = undefined
    const previous = this.search.getValue()
    this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`)
    if (this.search.getValue() !== previous) {
      this.selectedIndex = 0
      this.error = ''
    }
    if (remaining !== '') this.handleInput(remaining)
    this.invalidate()
    return true
  }

  handleInput(data: string): void {
    if (this.handleBracketedPaste(data)) return
    const filtered = this.filtered()
    if (matchesKey(data, Key.ctrl('c'))) {
      this.cancel()
      return
    }
    if (matchesKey(data, Key.escape)) {
      if (this.search.getValue() === '') this.cancel()
      else {
        this.search.setValue('')
        this.selectedIndex = 0
        this.error = ''
      }
    } else if (matchesKey(data, Key.up)) {
      this.selectedIndex = filtered.length === 0
        ? 0
        : (this.selectedIndex + filtered.length - 1) % filtered.length
    } else if (matchesKey(data, Key.down)) {
      this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
    } else if (matchesKey(data, Key.pageUp)) {
      this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount())
    } else if (matchesKey(data, Key.pageDown)) {
      this.selectedIndex = Math.min(
        Math.max(0, filtered.length - 1),
        this.selectedIndex + this.visibleCandidateCount(),
      )
    } else if (matchesKey(data, Key.enter)) {
      const selected = filtered[this.selectedIndex]
      if (selected === undefined) this.error = 'No session matches this search.'
      else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
      else this.done(selected)
    } else {
      const previous = this.search.getValue()
      this.search.focused = this.focused
      this.search.handleInput(data)
      if (this.search.getValue() !== previous) {
        this.selectedIndex = 0
        this.error = ''
      }
    }
    this.invalidate()
  }

  render(width: number): string[] {
    this.search.focused = this.focused
    const height = Math.max(1, this.viewportRows())
    const horizontalPadding = width >= 12 ? 2 : 0
    const contentWidth = Math.max(1, width - horizontalPadding * 2)
    const indent = ' '.repeat(horizontalPadding)
    const filtered = this.filtered()
    if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
    const selected = filtered[this.selectedIndex]
    const position = selected === undefined ? 0 : this.selectedIndex + 1
    const lines: string[] = [
      '',
      `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
      '',
    ]

    const searchInnerWidth = Math.max(1, contentWidth - 4)
    lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`)
    const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ')
    const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '')
    lines.push(
      `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`,
      `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`,
      '',
      `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`,
      '',
    )

    const visibleCount = this.visibleCandidateCount()
    const start = Math.max(0, Math.min(
      this.selectedIndex - Math.floor(visibleCount / 2),
      filtered.length - visibleCount,
    ))
    const end = Math.min(filtered.length, start + visibleCount)
    const push = (line: string): void => {
      lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`)
    }
    for (let index = start; index < end; index += 1) {
      const candidate = filtered[index] as ResumeCandidate
      const active = index === this.selectedIndex
      const status = [
        candidate.disabledReason === 'current session' ? 'current' : undefined,
        candidate.record.live ? 'live' : undefined,
        candidate.record.persisted ? 'persisted' : undefined,
      ].filter((value): value is string => value !== undefined).join(' · ')
      const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}`
      push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
      const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
      const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
      push(this.palette.muted(`  ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
      push(this.palette.dim(`  ${status} · ${displayText(candidate.record.header.id)}`))
      if (candidate.disabledReason !== undefined) {
        push(this.palette.warning(`  unavailable: ${displayText(candidate.disabledReason)}`))
      }
    }
    if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
    if (this.error !== '') {
      lines.push('')
      push(this.palette.error(displayText(this.error)))
    }

    const footer = `${indent}${this.palette.dim('Type to search  •  ↑/↓ navigate  •  Enter resume  •  Esc clear/cancel')}`
    while (lines.length < height - 2) lines.push('')
    lines.push(footer, '')
    return lines.slice(0, height)
  }
}

class QuestionDialog implements Component, Focusable {
  private selectedIndex = 0
  private selected = new Set<number>()
  private mode: 'options' | 'custom'
  private error = ''
  private readonly input = new Input()
  private readonly options: NonNullable<AskUserQuestionItem['options']>
  focused = false

  constructor(
    private readonly question: AskUserQuestionItem,
    private readonly position: number,
    private readonly total: number,
    private readonly unanswered: number,
    private readonly maxVisible: number,
    private readonly palette: Palette,
    private readonly done: (selection: QuestionSelection) => void,
    private readonly cancel: () => void,
  ) {
    this.options = question.options ?? []
    this.mode = this.options.length > 0 ? 'options' : 'custom'
    this.input.onSubmit = (value) => { this.submitCustom(value) }
    this.input.onEscape = () => {
      if (this.options.length > 0) {
        this.mode = 'options'
        this.error = ''
      } else {
        this.cancel()
      }
    }
  }

  invalidate(): void {
    this.input.invalidate()
  }

  handleInput(data: string): void {
    this.invalidate()
    if (this.mode === 'custom') {
      this.input.focused = this.focused
      this.input.handleInput(data)
      return
    }
    const options = this.options
    if (matchesKey(data, Key.up)) {
      this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
    } else if (matchesKey(data, Key.down)) {
      this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1
    } else if (matchesKey(data, Key.space) && this.question.multiSelect) {
      if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
      else this.selected.add(this.selectedIndex)
    } else if (matchesKey(data, Key.enter)) {
      const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
      if (indices.length === 0) {
        this.error = 'Select at least one option, or press Tab for a custom answer.'
        return
      }
      this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
    } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
      this.mode = 'custom'
      this.error = ''
    } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
      this.cancel()
    }
  }

  private submitCustom(value: string): void {
    const custom = value.trim()
    if (custom === '') {
      this.error = 'Enter an answer before submitting.'
      return
    }
    this.done({ selected: [], custom })
  }

  render(width: number): string[] {
    this.input.focused = this.focused
    const innerWidth = Math.max(1, width - 4)
    const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
    const lines = [
      this.palette.muted(header),
      ...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
    ]
    const push = (line: string): void => { lines.push(line) }
    // Supporting detail (e.g. the full plan under review) renders between the
    // question and the answer surface, kept out of option labels.
    if (this.question.detail !== undefined) {
      push('')
      for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line)
    }
    push('')
    if (this.mode === 'custom') {
      for (const line of this.input.render(innerWidth)) push(line)
      push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
    } else {
      const options = this.options
      const start = Math.max(0, Math.min(
        this.selectedIndex - Math.floor(this.maxVisible / 2),
        options.length - this.maxVisible,
      ))
      const end = Math.min(options.length, start + this.maxVisible)
      const optionRows = options.slice(start, end).map((option, offset) => {
        const index = start + offset
        const mark = this.question.multiSelect
          ? this.selected.has(index) ? '[x] ' : '[ ] '
          : ''
        return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
      })
      const descriptionColumn = Math.min(
        Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
        Math.max(1, Math.floor(innerWidth * 0.55)),
      )
      for (let index = start; index < end; index += 1) {
        // `index < end <= options.length`; the options array is borrowed immutably for this dialog.
        const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
        const mark = this.question.multiSelect
          ? this.selected.has(index) ? '[x] ' : '[ ] '
          : ''
        const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
        const leftStyled = index === this.selectedIndex
          ? this.palette.bold(this.palette.accent(left))
          : left
        const description = option.description === undefined
          ? ''
          : `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
        push(`${leftStyled}${description}`)
      }
      if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
      const hint = this.palette.dim(this.question.multiSelect
        ? 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt'
        : 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt')
      for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
    }
    if (this.error) {
      for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
    }
    return ['', ...lines, ''].map((line) => {
      const clipped = truncateToWidth(line, innerWidth, '')
      return `  ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))}  `
    })
  }
}

interface PendingQuestion {
  request: AskUserQuestionRequest
  index: number
  answers: AskUserQuestionAnswerItem[]
  resolve(answer: AskUserQuestionAnswer): void
  reject(error: unknown): void
  onAbort: () => void
  overlay: TuiOverlaySession | undefined
}

/** Merge path-only file candidates and optional session snapshots with commands. */
class ReferenceAutocompleteProvider implements AutocompleteProvider {
  constructor(
    private readonly base: CombinedAutocompleteProvider,
    private readonly files: WorkspaceFileSearch,
    private readonly sessions: SessionReferenceService | undefined,
    private readonly agent: Agent,
  ) {}

  async getSuggestions(
    lines: string[],
    cursorLine: number,
    cursorCol: number,
    options: { signal: AbortSignal; force?: boolean },
  ): Promise<AutocompleteSuggestions | null> {
    const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options)
    const currentLine = lines[cursorLine]
    /* v8 ignore next -- Editor always supplies its current state line. */
    if (currentLine === undefined) return basePromise
    const token = activeAtToken(currentLine, cursorCol)
    if (token === undefined) {
      this.files.invalidate()
      return basePromise
    }
    const filePromise = this.files.list(token.query, options.signal).catch(() => [])
    const sessionPromise = this.sessions === undefined || token.quoted
      ? Promise.resolve([])
      : this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => [])
    const [base, fileCandidates, sessionCandidates] = await Promise.all([
      basePromise,
      filePromise,
      sessionPromise,
    ])
    if (options.signal.aborted) return base
    const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => {
      const value = formatFileMention(candidate, token.quoted)
      if (value === undefined) return []
      const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
      const directory = candidate.kind === 'directory'
      return [{
        value,
        label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`,
        description: displayInlineText(candidate.path),
      }]
    })
    const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => {
      const mentionLabel = displayInlineText(candidate.label)
      const sessionId = displayInlineText(candidate.sessionId)
      const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
      const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
      return {
        value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }),
        label: `Session · ${mentionLabel}`,
        description,
      }
    })
    const items = [...fileItems, ...sessionItems]
    if (items.length === 0) return base
    return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
  }

  applyCompletion(
    lines: string[],
    cursorLine: number,
    cursorCol: number,
    item: AutocompleteItem,
    prefix: string,
  ): { lines: string[]; cursorLine: number; cursorCol: number } {
    return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
  }

  shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
    return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol)
  }
}

/** Lifecycle handle for a mounted interactive terminal channel. */
export interface TuiController {
  /** Stop rendering, restore the terminal, and reject pending questions. */
  dispose(): Promise<void>
}

/** Prefix that marks an editor submission as a manual skill invocation. */
const SKILL_COMMAND_PREFIX = '/skill:'

/** Parsed `/skill:<name> [instructions]` submission; `name` is empty when the prefix carries no name. */
interface ParsedSkillCommand {
  /** Skill name typed after `/skill:`, up to the first space. */
  name: string
  /** Trimmed text after the name; empty when none was typed. */
  instructions: string
}

/**
 * Split a `/skill:<name> [instructions]` submission into its name and trailing instructions.
 * @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}.
 * @returns the skill name and any trailing instructions.
 */
function parseSkillCommand(text: string): ParsedSkillCommand {
  const rest = text.slice(SKILL_COMMAND_PREFIX.length)
  const spaceIndex = rest.indexOf(' ')
  if (spaceIndex === -1) return { name: rest, instructions: '' }
  return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() }
}

/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */
function skillResourceReference(base: SkillResourceBase | undefined): string | undefined {
  if (base === undefined) return undefined
  switch (base.kind) {
    case 'directory':
      return `References in this skill are relative to ${base.path}.`
    case 'url':
      return `References in this skill are relative to ${base.url}.`
    case 'opaque':
      return base.description
    default:
      return assertNever(base, 'SkillResourceBase.kind')
  }
}

/**
 * Render a manually invoked skill into the model-visible user-message text. The
 * `<skill>` block carries the body and, when the provider supplies one, its
 * resource base; the trimmed `instructions` follow the block as the user's
 * request for this turn. The name is registry-validated kebab-case
 * ({@link SkillService} rejects any other) and the resource base is trusted
 * same-process provider prose, so — unlike the model-facing `dsh-tool-skill`
 * result, which escapes for a tool channel — this user turn is assembled raw.
 * @param skill - the loaded skill definition.
 * @param instructions - trimmed text typed after `/skill:<name>`; empty when absent.
 * @returns the user-message text delivered to the agent.
 */
export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string {
  const lines = [`<skill name="${skill.name}">`]
  const reference = skillResourceReference(skill.resourceBase)
  if (reference !== undefined) lines.push(reference, '')
  lines.push(skill.content, '</skill>')
  const block = lines.join('\n')
  return instructions === '' ? block : `${block}\n\n${instructions}`
}

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
}

/**
 * Start the interactive pi-tui channel for an already-created target agent.
 * @param ctx - agent, tools, session-event, and user-interaction context.
 * @param config - target agent, banner, and TUI presentation config.
 * @param runtime - terminal and process-exit boundary.
 * @returns lifecycle controller used by the Cordis effect disposer.
 */
export function createTuiChat(
  ctx: Context,
  config: Config,
  runtime: TuiRuntime,
): TuiController {
  const sessionId = SessionId(config.sessionId ?? 'main')
  const agent = ctx.agents.get(sessionId)
  if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
  const persistence = ctx.get('sessionPersistence')
  const sessionQuery = ctx.get('sessionQuery')
  const resolved = resolveTuiConfig(config)
  const palette = createPalette(resolved.color)
  const mdTheme = markdownTheme(palette)
  const ui = new TUI(runtime.terminal, resolved.showHardwareCursor)
  const chat = new Container()
  const todoContainer = new Container()
  const statusContainer = new Container()
  const editor = new Editor(ui, {
    borderColor: palette.dim,
    selectList: selectTheme(palette),
  } satisfies EditorTheme, { paddingX: 1 })
  const todo = new TodoComponent(palette)
  let showReasoning = resolved.showReasoning
  let toolsExpanded = false
  let streaming: StreamingAssistantComponent | undefined
  let runningStatus: RunningStatus | undefined
  // Steering messages queued during the running turn (`agent/inbox/enqueue`
  // with `info.steering`) that the loop has not yet drained, shown as a badge on
  // the status line. Each entry is the queued message's serialized source: a
  // drain (`steering/message`) removes one MATCHING entry, so a loop-authored
  // continuation reason (which enqueues and drains under its own source) pushes
  // and pops its own slot and cannot consume a pending user message's slot.
  // Cleared on leaving `running`, which also absorbs a cancellation that
  // discards the queue without logging drains; the status line exists only
  // while running, so idle carries no badge to keep current.
  const pendingSteering: string[] = []
  let disposed = false
  let shuttingDown: Promise<void> | undefined
  // Optional: skills mount conditionally, so read the global service store
  // rather than declaring an injection that would make the TUI require them.
  const skills = ctx.get('skills')
  const cwd = agent.session.header.cwd ?? process.cwd()
  const fileSearch = new WorkspaceFileSearch(cwd, {
    maxResults: resolved.fileSearchMaxResults,
    maxEntries: resolved.fileSearchMaxEntries,
    excludedDirectories: resolved.fileSearchExcludedDirectories,
  })
  const skillAbort = new AbortController()
  const tokens = sessionTokens(agent.session)
  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()
  const now = (): number => runtime.now?.() ?? Date.now()
  const agentStatus = (): AgentStatus => agent.status
  const isDisposed = (): boolean => disposed

  // A configured subtitle renders as a banner line; when absent, the banner has
  // no subtitle. The banner itself sweeps in on start (see startBannerReveal).
  let sessionTitle = foldSessionTitle(agent.session.events)?.title
  const header = new HeaderComponent(
    agent,
    () => sessionTitle ?? config.welcome,
    palette,
    resolved.color && resolved.truecolor,
    () => target.current === undefined ? undefined : compactTargetLabel(target.current),
  )
  const footer = new FooterComponent(
    agent,
    palette,
    () => toolsExpanded,
    () => tokens,
    runtime.formatCwd,
    () => target.current === undefined ? undefined : compactTargetLabel(target.current),
    () => contextWindow === undefined
      ? undefined
      : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
  )
  ui.addChild(header)
  ui.addChild(chat)
  ui.addChild(statusContainer)
  todoContainer.addChild(todo)
  ui.addChild(todoContainer)
  ui.addChild(editor)
  ui.addChild(footer)
  ui.setFocus(editor)
  const updateTerminalTitle = (): void => {
    runtime.terminal.setTitle(displayText(
      sessionTitle === undefined ? resolved.title : `${sessionTitle} — ${resolved.title}`,
    ))
  }
  updateTerminalTitle()

  const requestRender = (): void => {
    footer.invalidate()
    ui.requestRender()
  }

  const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => {
    const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted
    chat.addChild(new Spacer(1))
    chat.addChild(new Text(color(displayText(message)), 1, 0))
    requestRender()
  }

  const extensionTheme: TuiTheme = Object.freeze({
    text: (value: string) => palette.text(value),
    muted: (value: string) => palette.muted(value),
    dim: (value: string) => palette.dim(value),
    accent: (value: string) => palette.accent(value),
    success: (value: string) => palette.success(value),
    warning: (value: string) => palette.warning(value),
    error: (value: string) => palette.error(value),
    bold: (value: string) => palette.bold(value),
  })
  const overlayManager = new TuiOverlayManager({
    viewport: () => Object.freeze({
      columns: runtime.terminal.columns,
      rows: runtime.terminal.rows,
    }),
    theme: () => extensionTheme,
    display: displayText,
    show: (component, options) => ui.showOverlay(component, options === undefined
      ? undefined
      : {
        ...options,
        ...typeof options.margin === 'object'
          ? { margin: { ...options.margin } }
          : {},
      }),
    invalidate: requestRender,
    reportError: (error) => {
      const message = errorChain(error)
      ctx.logger.warn(`ui-tui: overlay failed: ${message}`)
      /* v8 ignore next -- shutdown removes overlays before the terminal stops */
      if (disposed) return
      appendNotice(`TUI overlay failed: ${message}`, 'error')
    },
  })

  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) => {
          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')
    })
  }

  const clearStatus = (): void => {
    if (runningStatus !== undefined) {
      clearInterval(runningStatus.timer)
      runningStatus.loader.stop()
      runningStatus = undefined
    }
    statusContainer.clear()
    runtime.terminal.setProgress(false)
  }

  // Refresh the status line's elapsed timers and queued badge from the
  // controller's phase and the current steering count.
  const renderStatus = (running: RunningStatus): void => {
    const at = now()
    running.loader.setMessage(
      formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.length),
    )
  }

  // Move to a derived phase, resetting the phase timer on a genuine change and
  // the step timer when a new step begins; ignored unless a turn is running.
  const enterPhase = (phase: TurnPhase, resetStep: boolean): void => {
    const running = runningStatus
    if (running === undefined) return
    const at = now()
    if (resetStep) running.stepStartedAt = at
    if (phase !== running.phase || resetStep) running.phaseStartedAt = at
    running.phase = phase
    renderStatus(running)
  }

  const setStatus = (status: AgentStatus): void => {
    // A running→running rebuild (a mid-turn palette swap re-derives the border)
    // carries the derived phase and both elapsed baselines across; only a fresh
    // idle→running turn starts at `waiting`.
    const prior = runningStatus
    clearStatus()
    editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
    if (status === 'running') {
      const at = now()
      const phase = prior?.phase ?? 'waiting'
      const phaseStartedAt = prior?.phaseStartedAt ?? at
      const stepStartedAt = prior?.stepStartedAt ?? at
      const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.length)
      const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message)
      statusContainer.addChild(loader)
      const running: RunningStatus = {
        loader,
        phase,
        phaseStartedAt,
        stepStartedAt,
        timer: setInterval(() => { renderStatus(running) }, STATUS_ELAPSED_INTERVAL_MS),
      }
      runningStatus = running
      runtime.terminal.setProgress(true)
    }
    requestRender()
  }

  // Refresh the running status line's queued-steering badge from the current
  // count; a no-op when idle because the controller only exists while running.
  const refreshStatus = (): void => {
    if (runningStatus !== undefined) renderStatus(runningStatus)
    requestRender()
  }

  // Derive the status-line phase from live session lifecycle events. The event
  // map is merge-extensible, so unhandled types fall through the default.
  const advanceTurnPhase = (event: SessionEvent): void => {
    switch (event.type) {
      case 'step/start':
        enterPhase('waiting', true)
        break
      case 'assistant/chunk': {
        const chunk = event.data.chunk
        if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) {
          enterPhase('thinking', false)
        } else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) {
          enterPhase('responding', false)
        }
        break
      }
      case 'tool/call':
        enterPhase('executing', false)
        break
      default:
        break
    }
  }

  const parsedTool = (event: Extract<SessionEvent, { type: 'tool/call' }>): ToolCardComponent => {
    const parsed = parseArguments(event.data.arguments)
    const card = new ToolCardComponent(
      event.data.name,
      parsed,
      ctx.tools.get(event.data.name, agent),
      resolved.maxToolOutputLines,
      palette,
    )
    card.setExpanded(toolsExpanded)
    toolCards.set(event.data.callId, card)
    allToolCards.add(card)
    return card
  }

  const clearStreaming = (): void => {
    if (streaming === undefined) return
    const index = chat.children.indexOf(streaming)
    /* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */
    if (index >= 0) chat.children.splice(index, 1)
    streaming = undefined
  }

  const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
    switch (event.type) {
      case 'user/message': {
        // Injected context (plugin/goal source) renders as a dim context card,
        // not a human bubble; only a direct human prompt is a user message. The
        // boolean avoids narrowing `source`, so the label keeps its full union.
        const source = event.data.source
        if (source.kind !== 'user') {
          const references = sessionReferenceCard(event.data.meta)
          if (references !== undefined) {
            chat.addChild(new Spacer(1))
            chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
            break
          }
          const text = displayText(contentText(event.data.content).trim())
          if (text) {
            // The tui type view lacks plugin-augmented source kinds (e.g. goal),
            // so read the display label without narrowing on `kind`.
            const labelled = source as { kind: string; plugin?: string }
            const label = labelled.plugin ?? labelled.kind
            chat.addChild(new Spacer(1))
            chat.addChild(new Text(palette.dim(`Context · ${displayText(label)}`), 1, 0))
            chat.addChild(new Text(palette.muted(text), 1, 0))
          }
          break
        }
        const text = displayText(contentText(displayPromptContent(event.data)).trim())
        if (text) {
          chat.addChild(new Spacer(1))
          chat.addChild(new UserMessageComponent(text, palette, mdTheme))
          if (options.addHistory) editor.addToHistory(text)
        }
        for (const references of promptReferenceCards(event)) {
          chat.addChild(new Spacer(1))
          chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
        }
        break
      }
      case 'steering/message': {
        const text = displayText(contentText(displayPromptContent(event.data)).trim())
        if (text) {
          chat.addChild(new Spacer(1))
          chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
        }
        for (const references of promptReferenceCards(event)) {
          chat.addChild(new Spacer(1))
          chat.addChild(new Text(palette.dim(`Referenced sessions · ${references.map(displayText).join(', ')}`), 1, 0))
        }
        break
      }
      case 'prompt/blocked':
        appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
        break
      case 'assistant/chunk':
        if (options.renderChunks) {
          if (streaming === undefined) {
            streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme)
            chat.addChild(streaming)
          }
          streaming.update(event.data.chunk)
        }
        break
      case 'assistant/message': {
        clearStreaming()
        const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme)
        if (component.children.length > 0) chat.addChild(component)
        break
      }
      case 'llm/retry': {
        clearStreaming()
        appendNotice(
          `Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`,
          'warning',
        )
        break
      }
      case 'tool/call':
        chat.addChild(new Spacer(1))
        chat.addChild(parsedTool(event))
        break
      case 'tool/result': {
        let card = toolCards.get(event.data.callId)
        if (card === undefined) {
          card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette)
          chat.addChild(new Spacer(1))
          chat.addChild(card)
          allToolCards.add(card)
        }
        card.updateResult(event.data)
        toolCards.delete(event.data.callId)
        break
      }
      case 'todo/write':
        todo.update(event.data.todos)
        break
      case 'session/title':
        sessionTitle = event.data.title
        header.invalidate()
        updateTerminalTitle()
        break
      case 'turn/end':
        clearStreaming()
        if (event.data.reason.kind === 'error') {
          const key = `${event.data.turn}:${event.data.reason.step}`
          const message = 'failure' in event.data.reason
            ? event.data.reason.failure.message
            : event.data.reason.message
          if (!liveErrors.delete(key)) appendNotice(message, 'error')
        } else if (event.data.reason.kind === 'aborted') {
          appendNotice('Turn cancelled.', 'warning')
        } else if (event.data.reason.kind === 'max-tokens') {
          appendNotice('The model reached its output-token limit.', 'warning')
        } else if (event.data.reason.kind === 'rejected') {
          appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning')
        } else if (event.data.reason.kind === 'interrupted') {
          appendNotice('The previous process ended during this turn.', 'warning')
        }
        break
      default:
        break
    }
  }

  const rebuildTranscript = (populateHistory: boolean): void => {
    chat.clear()
    toolCards.clear()
    allToolCards.clear()
    streaming = undefined
    const active = activeSurfaceSeqs(agent.session)
    const activeCalls = activeToolCallIds(agent.session, active)
    for (const event of agent.session.events) {
      const isSurface = event.type === 'user/message'
        || event.type === 'assistant/message'
        || event.type === 'tool/result'
        || event.type === 'steering/message'
      if (isSurface && !active.has(event.seq)) continue
      if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
      renderEvent(event, { addHistory: populateHistory, renderChunks: false })
    }
    requestRender()
  }

  const removeAbortListener = (pending: PendingQuestion): void => {
    pending.request.signal?.removeEventListener('abort', pending.onAbort)
  }

  const rejectQuestion = (pending: PendingQuestion): void => {
    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()
      })
    },
  })

  /**
   * 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 shutdown = (exitProcess: boolean): Promise<void> => {
    shuttingDown ??= (async () => {
      disposed = true
      overlayManager.beginShutdown()
      contextResolution = undefined
      clearStatus()
      for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
      commandControllers.clear()
      for (const controller of referenceControllers) controller.abort(new Error('TUI disposed'))
      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)
      await overlayManager.dispose()
      modelOverlay = undefined
      disposeUserInteraction()
      await runtime.terminal.drainInput(100, 20)
      ui.stop()
      if (exitProcess) {
        const command = await currentResumeCommand()
        if (command !== undefined) {
          runtime.terminal.write(`${palette.muted('To resume this session:')} ${displayText(command)}\n`)
        }
        runtime.exit(0)
      }
    })()
    return shuttingDown
  }

  const requestExit = (): void => {
    if (agent.status === 'running') {
      agent.cancel({ kind: 'user' })
      appendNotice('Cancelling the active turn before exit…', 'warning')
      void agent.whenIdle().then(() => shutdown(true))
      return
    }
    void shutdown(true)
  }

  /** Swap the palette and all derived themes for the given terminal color scheme. */
  const applyColorScheme = (scheme: TerminalColorScheme): void => {
    if (scheme === currentScheme) return
    currentScheme = scheme
    Object.assign(palette, createPalette(resolved.color, scheme))
    Object.assign(mdTheme, markdownTheme(palette))
    // `setStatus` below re-derives `editor.borderColor` from the new palette.
    rebuildTranscript(false)
    setStatus(agent.status)
    requestRender()
  }
  let currentScheme: TerminalColorScheme = 'dark'

  // Apply any color scheme the terminal reports. Registering before the query
  // below means even a synchronous reply reaches `applyColorScheme`; in practice
  // the startup query's reply is the only report, since dsh-tui leaves
  // unsolicited color-scheme notifications disabled.
  const disposeSchemeListener = ui.onTerminalColorSchemeChange(applyColorScheme)

  // Ask the terminal for its color scheme via device-status report; the reply,
  // if any, arrives through the listener above. Most terminals do not respond,
  // so we keep the dark-optimised palette. Swallow a query-write failure for the
  // same reason.
  ui.queryTerminalColorScheme({ timeoutMs: 2000 }).catch(() => {})

  const toggleTools = (): void => {
    toolsExpanded = !toolsExpanded
    for (const card of allToolCards) card.setExpanded(toolsExpanded)
    appendNotice(`Tool cards ${toolsExpanded ? 'expanded' : 'collapsed'}.`)
  }

  const toggleReasoning = (): void => {
    showReasoning = !showReasoning
    const activeStreaming = streaming
    rebuildTranscript(false)
    if (activeStreaming !== undefined) {
      streaming = activeStreaming
      streaming.setShowReasoning(showReasoning)
      chat.addChild(activeStreaming)
    }
    appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`)
  }

  const showHelp = (): void => {
    const commandLines = ctx.commands.list(agent).map((command) => {
      const input = command.input === undefined ? '' : ` ${command.input.hint}`
      return `/${command.name}${input} — ${command.description}`
    })
    chat.addChild(new Spacer(1))
    chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
    chat.addChild(new Text([
      'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
      'Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning',
      'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
      '',
      ...commandLines,
      '/skill:<name> [instructions] — load a skill into the conversation',
    ].map(line => palette.muted(line)).join('\n'), 1, 0))
    requestRender()
  }

  const showStatus = (): void => {
    const events = agent.session.events
    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`
    if (contextWindow !== undefined) {
      const contextPercent = Math.round(usedContext / contextWindow * 100)
      context = `${diagnosticMeter(contextPercent, palette)} ${String(contextPercent)}% used (${formatDiagnosticNumber(usedContext)} / ${formatDiagnosticNumber(contextWindow)})`
    }
    const rate = cacheHitRate(tokens)
    const turns = events.filter(event => event.type === 'turn/start').length
    const steps = events.filter(event => event.type === 'step/start').length
    const toolCalls = events.filter(event => event.type === 'tool/call').length
    const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current))
    const effort = target.current === undefined
      ? 'unset'
      : target.current.reasoningEffort === undefined
        ? 'default'
        : displayText(target.current.reasoningEffort)
    const groups: readonly (readonly StatusCardRow[])[] = [
      [
        ['Session', displayText(agent.session.id)],
        ['Title', displayText(sessionTitle ?? 'untitled')],
        ['Directory', displayText(cwd)],
        ['Model', `${model} ${palette.dim(`(effort ${effort}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'})`)}`],
      ],
      [
        ['Agent', [
          agent.status,
          formatDiagnosticCount(events.length, 'event'),
          formatDiagnosticCount(turns, 'turn'),
          formatDiagnosticCount(steps, 'step'),
          formatDiagnosticCount(toolCalls, 'tool call'),
        ].join(' · ')],
      ],
      [
        ['Tokens', `${formatDiagnosticNumber(tokens.input)} input + ${formatDiagnosticNumber(tokens.output)} output`],
        ['KV cache', rate === undefined
          ? `n/a (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)`
          : `${diagnosticMeter(rate, palette)} ${String(rate)}% hit (${formatDiagnosticNumber(tokens.cacheRead)} read + ${formatDiagnosticNumber(tokens.cacheWrite)} write)`],
        ['Context', context],
      ],
      [
        ['Created', formatDiagnosticTime(agent.session.header.createdAt)],
        ['Active', formatDiagnosticTime(latestActivity)],
      ],
    ]
    const card = new StatusCardComponent(groups, palette)
    chat.addChild(new Spacer(1))
    chat.addChild(card)
    requestRender()
  }

  // Skill listing is async while `createTuiChat` is synchronous, so the
  // completions rebuild once the catalog resolves. Disabled-for-model skills
  // are absent from `list()`, so they never appear as completions; a user can
  // still invoke one by typing its exact name.
  let skillCommands: SlashCommand[] = []
  const refreshCommandAutocomplete = (): void => {
    const base = new CombinedAutocompleteProvider(
      [
        ...ctx.commands.list(agent).map(command => ({
          name: command.name,
          description: command.description,
          ...(command.input === undefined ? {} : { argumentHint: command.input.hint }),
        })),
        ...skillCommands,
      ],
      agent.session.header.cwd ?? process.cwd(),
    )
    const sessionReferences = ctx.get('sessionReferences')
    editor.setAutocompleteProvider(new ReferenceAutocompleteProvider(
      base,
      fileSearch,
      sessionReferences,
      agent,
    ))
  }
  const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
  refreshCommandAutocomplete()

  const loadSkillCommands = (service: SkillService): void => {
    service.list({ cwd, signal: skillAbort.signal }).then(
      (summaries) => {
        if (disposed || summaries.length === 0) return
        skillCommands = summaries.map(skill => ({
          name: `skill:${skill.name}`,
          description: skill.description,
          argumentHint: '[instructions]',
        }))
        refreshCommandAutocomplete()
        requestRender()
      },
      () => {
        // Discovery failed or was aborted on dispose; keep the base slash
        // commands so autocomplete still works without skill entries.
      },
    )
  }
  if (skills !== undefined) loadSkillCommands(skills)

  // The agent scope is minted by agent-loop and intentionally inherits only
  // that core plugin's dependencies. A child command producer declares its own
  // UI-service dependency while retaining the parent agent scope and lifetime.
  const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => {
    commandCtx.commands.register({
      name: 'help',
      description: 'Show keyboard shortcuts and commands',
      handler: () => { showHelp(); return { kind: 'success' } },
    })
    commandCtx.commands.register({
      name: 'model',
      description: 'Show or switch this session\'s model',
      input: { hint: '[[provider/]model]' },
      handler: ({ rawInput }) => {
        queueModelCommand(rawInput)
        return { kind: 'success' }
      },
    })
    commandCtx.commands.register({
      name: 'clear',
      description: 'Clear the transcript view (session history is unchanged)',
      handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
    })
    commandCtx.commands.register({
      name: 'reasoning',
      description: 'Toggle reasoning blocks',
      handler: () => { toggleReasoning(); return { kind: 'success' } },
    })
    commandCtx.commands.register({
      name: 'tools',
      description: 'Expand or collapse all tool cards',
      handler: () => { toggleTools(); return { kind: 'success' } },
    })
    commandCtx.commands.register({
      name: 'redraw',
      description: 'Invalidate components and redraw the terminal',
      handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
    })
    commandCtx.commands.register({
      name: 'reload',
      description: 'EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only)',
      handler: () => { runReload(); return { kind: 'success' } },
    })
    commandCtx.commands.register({
      name: 'resume',
      description: 'List this workspace\'s resumable sessions',
      handler: () => { showResume(); return { kind: 'success' } },
    })
    commandCtx.commands.register({
      name: 'status',
      description: 'Show detailed session diagnostics',
      handler: () => { showStatus(); return { kind: 'success' } },
    })
    commandCtx.commands.register({
      name: 'exit',
      description: 'Exit after the active turn reaches idle',
      handler: () => { requestExit(); return { kind: 'success' } },
    })
  })
  const fileReferencePromptFiber = agent.ctx.inject(['systemPrompt'], (promptCtx) => {
    promptCtx.systemPrompt.section({
      name: 'ui:tui-file-reference',
      order: 99,
      // Tool visibility can change dynamically or by agent scope. Empty
      // sections are omitted by renderPrompt, so guidance never names a tool
      // that this agent cannot call.
      text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT,
    })
  })

  const runCommand = (text: string): void => {
    const controller = new AbortController()
    commandControllers.add(controller)
    void ctx.commands.execute(agent, text, controller.signal).then(
      (result) => {
        if (disposed) return
        if (result === undefined) {
          appendNotice(`Unknown command: ${text}`, 'warning')
        } else if (result.text !== undefined && result.text !== '') {
          appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
        }
      },
      (error: unknown) => {
        if (!disposed) {
          appendNotice(`Command failed: ${errorChain(error)}`, 'error')
        }
      },
    ).finally(() => { commandControllers.delete(controller) })
  }

  const dispatchMessage = (content: ContentBlock[], contexts: HookContext[]): void => {
    if (agent.status === 'disposed') {
      appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
    } else if (agent.status === 'running') {
      agent.steer(content, { contexts })
    } else {
      agent.followup(content, { contexts })
    }
  }

  /** Deliver a user turn to the agent: steer while running, send while idle, or report a disposed agent. */
  const deliver = (payload: string): void => {
    dispatchMessage([{ type: 'text', text: payload }], [])
  }

  /** Load a manually invoked skill and deliver its rendered body as a user turn, reporting lookup outcomes as notices. */
  const invokeSkill = (name: string, instructions: string): void => {
    if (skills === undefined) {
      appendNotice('Skills are not available in this session.', 'warning')
      return
    }
    skills.get(name, { cwd, signal: skillAbort.signal }).then(
      (skill) => {
        if (disposed) return
        if (skill === undefined) {
          appendNotice(`Unknown skill: ${name}`, 'warning')
          return
        }
        deliver(renderSkillInvocation(skill, instructions))
      },
      (error: unknown) => {
        if (disposed) return
        appendNotice(`Skill "${name}" failed to load: ${errorChain(error)}`, 'error')
      },
    )
  }

  // EXPERIMENTAL, dev-only: manually re-read every file-backed loader config
  // tree and apply the diff to the running app — the same path the HMR
  // watcher's config-change branch drives, minus the watcher. Useful when the
  // watcher misses an edit (replace-by-rename saves) or HMR is not mounted.
  // Module-source hot reload stays watcher-owned; this refreshes configs only.
  let reloadInFlight = false
  const runReload = (): void => {
    // Idle-only: a reload can dispose and re-mount entries mid-flight; doing
    // that under an active turn could tear tools or the adapter out from
    // under in-flight calls. Idleness is advisory (a send can race in after
    // the check), but it removes the common footgun.
    if (agent.status !== 'idle') {
      appendNotice(`/reload requires an idle agent (status: ${agent.status}).`, 'warning')
      return
    }
    // Re-entrancy guard: concurrent refreshes over a genuinely changed file
    // would race unmutexed tree updates (create/remove interleaving); one
    // reload at a time keeps the update pass single-writer.
    if (reloadInFlight) {
      appendNotice('A config reload is already running.', 'warning')
      return
    }

    // Optional-service lookup: the TUI must not depend on the Loader (tests
    // and embedders run without one), so `loader` stays out of `inject` and
    // is read through the non-throwing `ctx.get` accessor — a bare `ctx.loader`
    // proxy read would throw `cannot get property without inject` in a fiber.
    const loader = ctx.get('loader') as { entries(): Iterable<{ subtree?: { refresh?(): Promise<void> } }> } | undefined
    if (loader === undefined) {
      appendNotice('/reload needs the cordis Loader; this runtime has none.', 'warning')
      return
    }
    const refreshes: Promise<void>[] = []
    for (const entry of loader.entries()) {
      if (entry.subtree?.refresh !== undefined) refreshes.push(entry.subtree.refresh())
    }
    reloadInFlight = true
    appendNotice(`Reloading ${refreshes.length} config tree(s)… (experimental)`)
    // refresh() never rejects (it warns and keeps the running tree), so the
    // join can only fulfill; the catch arm guards a future contract change.
    void Promise.all(refreshes).then(() => {
      appendNotice('Config reload complete. Unchanged files were skipped; invalid files keep the running tree (see logs).')
    }).catch((error: unknown) => {
      appendNotice(`Config reload failed: ${errorChain(error)}`, 'error')
    }).finally(() => {
      reloadInFlight = false
    })
  }

  /** 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
    const restoreSubmittedInput = (): void => {
      if (editor.getText() === '') editor.setText(value)
    }
    // `/skill:<name>` carries a colon, which the command registry's name
    // grammar rejects, so it is intercepted before generic command routing.
    if (text.startsWith(SKILL_COMMAND_PREFIX)) {
      editor.addToHistory(text)
      editor.setText('')
      const { name, instructions } = parseSkillCommand(text)
      if (name === '') appendNotice('Usage: /skill:<name> [instructions]', 'warning')
      else invokeSkill(name, instructions)
      return
    }
    if (value.startsWith('/')) {
      editor.addToHistory(text)
      editor.setText('')
      runCommand(value)
      return
    }
    let parsed: ReturnType<typeof parseSessionReferenceText>
    try {
      parsed = parseSessionReferenceText(text)
    } catch (error: unknown) {
      restoreSubmittedInput()
      appendNotice(`Invalid session reference: ${errorChain(error)}`, 'error')
      return
    }
    if (parsed.references.length === 0) {
      editor.addToHistory(text)
      editor.setText('')
      dispatchMessage([{ type: 'text', text: parsed.text }], [])
      return
    }
    const sessionReferences = ctx.get('sessionReferences')
    if (sessionReferences === undefined) {
      restoreSubmittedInput()
      appendNotice('Session reference capability unavailable.', 'error')
      return
    }
    const controller = new AbortController()
    referenceControllers.add(controller)
    editor.disableSubmit = true
    void sessionReferences.prepare(
      agent,
      [{ type: 'text', text: parsed.text }],
      parsed.references,
      controller.signal,
    ).then((prepared) => {
      if (disposed) return
      editor.addToHistory(text)
      if (editor.getText() === value) editor.setText('')
      dispatchMessage(prepared.content, prepared.contexts)
    }, (error: unknown) => {
      if (!disposed && !controller.signal.aborted) {
        restoreSubmittedInput()
        appendNotice(`Session reference failed: ${errorChain(error)}`, 'error')
      }
    }).finally(() => {
      referenceControllers.delete(controller)
      editor.disableSubmit = false
      requestRender()
    })
  }

  const removeInputListener = ui.addInputListener((data) => {
    if (overlayManager.hasActiveOverlay()) return undefined
    if (matchesKey(data, Key.ctrl('o'))) {
      toggleTools()
      return { consume: true }
    }
    if (matchesKey(data, Key.ctrl('r'))) {
      toggleReasoning()
      return { consume: true }
    }
    if (matchesKey(data, Key.ctrl('l'))) {
      ui.invalidate()
      ui.requestRender(true)
      return { consume: true }
    }
    if (matchesKey(data, Key.escape) && agent.status === 'running') {
      agent.cancel({ kind: 'user' })
      return { consume: true }
    }
    if (matchesKey(data, Key.ctrl('c'))) {
      if (agent.status === 'running') {
        agent.cancel({ kind: 'user' })
      } else if (editor.getText() !== '') {
        editor.setText('')
      } else {
        requestExit()
      }
      return { consume: true }
    }
    if (matchesKey(data, Key.ctrl('d'))) {
      if (agent.status === 'running') appendNotice('Cancel the active turn before exiting.', 'warning')
      else requestExit()
      return { consume: true }
    }
    return undefined
  })

  const disposeSessionEvents = ctx.on('session/event', (session, event) => {
    if (session !== agent.session) return
    if (event.type === 'tool/result') fileSearch.invalidate()
    recordEventUsage(tokens, event)
    advanceTurnPhase(event)
    if (event.type === 'steering/message') {
      // A queued steering message reached the model as it drained; drop its
      // entry from the badge. Matching by source keeps a loop-authored
      // continuation reason popping its own enqueued slot rather than a pending
      // user message's slot.
      const drained = pendingSteering.indexOf(JSON.stringify(event.data.source))
      if (drained >= 0) {
        pendingSteering.splice(drained, 1)
        refreshStatus()
      }
    }
    if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
      rebuildTranscript(false)
      return
    }
    renderEvent(event, { addHistory: false, renderChunks: true })
    requestRender()
  })
  const disposeQueued = ctx.on('agent/inbox/enqueue', (subject, info) => {
    if (subject !== agent || !info.steering) return
    pendingSteering.push(JSON.stringify(info.source))
    refreshStatus()
  })
  const disposeStatus = ctx.on('agent/status', (subject, status) => {
    if (subject !== agent) return
    // Leaving 'running' ends the turn's status line; clear any badge so the
    // next running turn starts from zero (and a cancellation, which discards
    // the queue without logging drains, cannot strand a stale count).
    if (status !== 'running') pendingSteering.length = 0
    setStatus(status)
  })
  const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
    if (subject !== agent) return
    liveErrors.add(`${turn}:${step}`)
    // Full cause chain: wrapper messages like `fetch failed` carry the
    // actionable transport detail on `cause`.
    appendNotice(errorChain(error), 'error')
  })
  const disposeAgent = ctx.on('agent/disposed', (subject) => {
    if (subject !== agent) return
    clearStatus()
    appendNotice(`Agent "${agent.id}" was disposed.`, 'warning')
  })

  const detachListeners = (): void => {
    skillAbort.abort()
    fileSearch.dispose()
    removeInputListener()
    disposeCommandChanges()
    stopBannerReveal()
    disposeSessionEvents()
    disposeQueued()
    disposeStatus()
    disposeError()
    disposeAgent()
    disposeSchemeListener()
    disposeTargetListeners()
  }

  // Sweep reveal of the whole banner: the header wipes in left-to-right over
  // ~BANNER_REVEAL_STEPS frames (started after `ui.start()` succeeds).
  // Configured subtitles skip it so deployments (and snapshot fixtures) stay
  // frame-deterministic.
  let revealTimer: ReturnType<typeof setInterval> | undefined
  const stopBannerReveal = (): void => {
    if (revealTimer === undefined) return
    clearInterval(revealTimer)
    revealTimer = undefined
    header.setRevealWidth(undefined)
  }
  const startBannerReveal = (): void => {
    if (config.welcome !== undefined) return
    const total = Math.max(1, runtime.terminal.columns)
    const step = Math.max(1, Math.ceil(total / BANNER_REVEAL_STEPS))
    let shown = 0
    header.setRevealWidth(0)
    revealTimer = setInterval(() => {
      shown += step
      if (shown >= total) {
        stopBannerReveal()
      } else {
        header.setRevealWidth(shown)
      }
      requestRender()
    }, BANNER_REVEAL_INTERVAL_MS)
  }

  rebuildTranscript(true)
  const restoredGoal = foldGoal(agent.session.events).goal
  if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') {
    appendNotice(
      `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. `
      + 'Human confirmation is required; send “继续” or run /goal resume.',
      'warning',
    )
  }
  setStatus(agent.status)
  try {
    ui.start()
  } catch (error: unknown) {
    disposed = true
    detachListeners()
    void Promise.all([
      commandFiber.dispose(),
      fileReferencePromptFiber.dispose(),
    ]).catch(
      /* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
      (cleanupError: unknown) => {
        ctx.logger.warn(`ui-tui: scoped cleanup after startup failure failed: ${errorChain(cleanupError)}`)
      },
    )
    clearStatus()
    disposeUserInteraction()
    ui.stop()
    throw error
  }
  tuiServiceFiber = ctx.inject([], (serviceCtx) => {
    new TuiExtensionServiceImpl(serviceCtx, agent, overlayManager)
  })
  startBannerReveal()

  return {
    async dispose(): Promise<void> {
      detachListeners()
      await shutdown(false)
      await Promise.all([
        commandFiber.dispose(),
        fileReferencePromptFiber.dispose(),
      ])
    },
  }
}

/**
 * Open the pi-tui channel once its configured agent exists.
 *
 * @param ctx - Context supplying the agent registry, tools, and event stream.
 * @param config - Target agent and presentation configuration.
 * @param runtime - Terminal and process-exit boundary.
 */
export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void {
  const sessionId = SessionId(config.sessionId ?? 'main')
  const matchesConfiguredIdentity = (agent: Agent): boolean =>
    agent.id === sessionId && ctx.agents.roots().includes(agent)
  let settled = false

  const stopWaiting = (): void => {
    disposeCreated()
    disposeFailure()
  }
  const start = (agent: Agent): void => {
    if (settled || !matchesConfiguredIdentity(agent)) return
    settled = true
    stopWaiting()
    ctx.effect(() => {
      const controller = createTuiChat(ctx, config, runtime)
      return () => controller.dispose()
    }, 'ui-tui')
  }
  const fail = (failedSessionId: SessionId, error: unknown): void => {
    if (settled || failedSessionId !== sessionId) return
    settled = true
    stopWaiting()
    runtime.terminal.write(displayText(`ui-tui: session "${sessionId}" failed to start: ${errorChain(error)}\n`))
    runtime.exit(1)
  }

  const disposeCreated = ctx.on('agent/created', start)
  const disposeFailure = ctx.on('agent-loop/config-start-failed', fail)
  const existing = ctx.agents.roots().find(agent => agent.id === sessionId)
  if (existing !== undefined) start(existing)
}

/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */
/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat,
   and the tui-agent PTY smoke covers the real entry */
export function apply(ctx: Context, config: Config): void {
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
    throw new Error('ui-tui: both stdin and stdout must be TTYs; use the one-shot @deepseek-ai/dsh-cli-demo app for pipes')
  }
  // Truecolor is a terminal capability, so detect it here at the process
  // boundary from COLORTERM; an explicit `truecolor` config value still wins.
  const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '')
  const resumeHost = ctx.get('tuiResumeHost')
  mountTui(ctx, Object.assign({}, config, { truecolor }), {
    terminal: new ProcessTerminal(),
    exit: code => process.exit(code),
    ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) },
  })
}
/* v8 ignore stop */
