/**
 * 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,
  Markdown,
  Spacer,
  Text,
  TUI,
  ProcessTerminal,
  SelectList,
  matchesKey,
  truncateToWidth,
  visibleWidth,
  wrapTextWithAnsi,
  type Component,
  type EditorTheme,
  type Focusable,
  type MarkdownTheme,
  type OverlayHandle,
  type SelectListTheme,
  type Terminal,
  type TerminalColorScheme,
} from '@earendil-works/pi-tui'
import type { Context } from 'cordis'
import z from 'schemastery'
import {
  installAgentLlmTarget,
  type Agent,
  type AgentLlmTarget,
  type AgentLlmTargetRef,
  type AgentStatus,
} 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 { errorChain } from '@deepseek-ai/dsh-llm'
import type {
  ContentBlock,
  LlmModelInfo,
  StreamChunk,
  TokenUsage,
} from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
  FileDiff,
  TerminalCallView,
  ToolCallView,
  ToolDefinition,
  ToolResultView,
} from '@deepseek-ai/dsh-tools'
import {
  UserInteractionError,
  type AskUserQuestionAnswer,
  type AskUserQuestionAnswerItem,
  type AskUserQuestionItem,
  type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'

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

/** 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
  /** 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
  /** Show the terminal's hardware cursor at the pi editor's IME marker. */
  showHardwareCursor?: boolean
  /** Apply the built-in ANSI color palette. */
  color?: boolean
  /** Terminal window title while the UI is mounted. */
  title?: string
}

const showReasoningSchema = z.boolean().default(true)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = 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(72)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const showHardwareCursorSchema = z.boolean().default(false)
const colorSchema = z.boolean().default(true)
const titleSchema = z.string().default('DeepSeek Harness')

/** Schemastery schema for presentation settings embedded by app bundles. */
export const TuiConfigSchema: z<TuiConfig> = z.object({
  showReasoning: showReasoningSchema,
  maxToolOutputLines: maxToolOutputLinesSchema,
  maxQuestionOptions: maxQuestionOptionsSchema,
  maxModelOptions: maxModelOptionsSchema,
  questionDialogWidth: questionDialogWidthSchema,
  questionDialogMaxHeight: questionDialogMaxHeightSchema,
  modelDialogWidth: modelDialogWidthSchema,
  modelDialogMaxHeight: modelDialogMaxHeightSchema,
  showHardwareCursor: showHardwareCursorSchema,
  color: colorSchema,
  title: titleSchema,
})

/** Serializable plugin configuration. */
export interface Config extends TuiConfig {
  /** Header subtitle. Defaults to `ready.`. */
  welcome?: string
  /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
  sessionId?: string
}

export const Config: z<Config> = z.object({
  welcome: z.string().default('ready.'),
  sessionId: z.string().default('main'),
  showReasoning: showReasoningSchema,
  maxToolOutputLines: maxToolOutputLinesSchema,
  maxQuestionOptions: maxQuestionOptionsSchema,
  maxModelOptions: maxModelOptionsSchema,
  questionDialogWidth: questionDialogWidthSchema,
  questionDialogMaxHeight: questionDialogMaxHeightSchema,
  modelDialogWidth: modelDialogWidthSchema,
  modelDialogMaxHeight: modelDialogMaxHeightSchema,
  showHardwareCursor: showHardwareCursorSchema,
  color: colorSchema,
  title: titleSchema,
})

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

/** Runtime boundary used by the interactive TUI. */
export interface TuiRuntime {
  /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
  terminal: Terminal
  /** Exit hook used by terminal shutdown or a target-agent startup failure. */
  exit(code: number): void
  /**
   * 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
}

/**
 * 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,
    questionDialogWidth: config?.questionDialogWidth ?? 200,
    questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
    modelDialogWidth: config?.modelDialogWidth ?? 72,
    modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
    showHardwareCursor: config?.showHardwareCursor ?? false,
    color: config?.color ?? true,
    title: config?.title ?? 'DeepSeek Harness',
  }
}

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

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

const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu

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

/**
 * 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),
  }
}

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
}

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

function initialTarget(agent: Agent): AgentLlmTarget | undefined {
  const logged = agent.session.requestHeader()?.config
  if (logged !== undefined) return { provider: logged.provider, model: logged.model }
  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 models.map((model): ModelChoice => ({
      provider: provider.id,
      model: model.id,
      modelName: model.name,
      ...model.description === undefined ? {} : { description: model.description },
    }))
  }))
  return groups.flat()
}

class HeaderComponent implements Component {
  constructor(
    private readonly agent: Agent,
    private readonly subtitle: () => string,
    private readonly palette: Palette,
    private readonly currentModel: () => string | undefined,
  ) {}

  invalidate(): void {}

  render(width: number): string[] {
    const usable = Math.max(1, width - 4)
    const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}`
    const model = displayText(this.currentModel() ?? 'model unset')
    const detail = `${model}  •  ${displayText(this.agent.session.id)}`
    const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`)
    const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)
    const lines = [title, this.palette.muted(displayText(this.subtitle())), this.palette.dim(detail)]
      .flatMap(line => wrapTextWithAnsi(line, usable))
      .map((line) => {
        const clipped = truncateToWidth(line, usable, '')
        return `${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, usable - visibleWidth(clipped)))} ${this.palette.accent('│')}`
      })
    return [top, ...lines, bottom]
  }
}

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

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

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

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

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

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

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

interface StreamingBlock {
  type: string
  text: string
}

class StreamingAssistantComponent extends Container {
  private readonly blocks = new Map<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?: unknown } | undefined
  private expanded = false
  private callView: ToolCallView
  private resultView: ToolResultView | undefined

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

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

  updateResult(event: Extract<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
}

interface SessionTokenTotals {
  input: number
  output: 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.byStep.set(key, usage)
  totals.input += usage.inputTokens
  totals.output += usage.outputTokens
}

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

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

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

  invalidate(): void {}

  render(width: number): string[] {
    if (this.agent.status === 'running') {
      const interrupt = this.palette.dim('esc interrupt')
      const activityAvailable = Math.max(0, width - visibleWidth(interrupt) - 1)
      const activity = truncateToWidth(this.palette.accent(`◒ Working · ${this.runningSeconds()}s`), activityAvailable, '')
      const gap = ' '.repeat(Math.max(0, width - visibleWidth(activity) - visibleWidth(interrupt)))
      return [`${activity}${gap}${interrupt}`]
    }
    const { input, output } = this.tokens()
    const counters = `↑${formatTokens(input)} ↓${formatTokens(output)}`
    const model = displayText(this.currentModel() ?? 'model unset')
    const modelState = `${model}(reasoning:${this.showReasoning() ? 'on' : 'off'})`
    const contextPercent = this.contextPercent()
    const context = contextPercent === undefined ? 'context unknown' : `${contextPercent}% context`
    const fullRight = `${context}  tools:${this.toolsExpanded() ? 'expanded' : 'compact'}  ${modelState}`
    const compactRight = `${context}  ${modelState}`
    const formattedCwd = displayText(
      this.cwdFormatter?.(this.agent.session.header.cwd) ?? formatCwd(this.agent.session.header.cwd),
    )
    if (visibleWidth(counters) + visibleWidth(compactRight) + 1 > width) {
      const compact = truncateToWidth(compactRight, width, '')
      return [`${' '.repeat(Math.max(0, width - visibleWidth(compact)))}${this.palette.dim(compact)}`]
    }
    const rightAvailable = width - visibleWidth(counters) - 1
    const right = visibleWidth(fullRight) <= rightAvailable ? fullRight : compactRight
    const rightClipped = truncateToWidth(right, rightAvailable, '')
    const cwdAvailable = Math.max(0, width - visibleWidth(counters) - visibleWidth(rightClipped) - 3)
    const cwd = truncateToWidth(formattedCwd, cwdAvailable, '')
    const left = [cwd, counters].filter(Boolean).join('  ')
    const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - visibleWidth(rightClipped)))
    return [`${this.palette.dim(left)}${gap}${this.palette.dim(rightClipped)}`]
  }
}

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

  constructor(
    choices: readonly ModelChoice[],
    current: AgentLlmTarget | undefined,
    maxVisible: number,
    private readonly palette: Palette,
    done: (choice: ModelChoice) => void,
    cancel: () => void,
  ) {
    this.list = new SelectList(choices.map(choice => ({
      value: targetLabel(choice),
      label: displayText(targetLabel(choice)),
      description: [
        displayText(choice.modelName),
        ...choice.description === undefined ? [] : [displayText(choice.description)],
        ...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [],
      ].join(' — '),
    })), 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(selected)
    }
    this.list.onCancel = cancel
  }

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

  handleInput(data: string): void {
    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 • Enter select • Esc cancel'),
    ], width, this.palette)
  }
}

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) }
    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: OverlayHandle | undefined
}

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

function activeSurfaceSeqs(session: Session): Set<number> {
  return new Set(session.surface.nodes)
}

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 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 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 runningStartedAt: number | undefined
  let statusTicker: ReturnType<typeof setInterval> | undefined
  let disposed = false
  let shuttingDown: Promise<void> | undefined
  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>()
  let activeQuestion: PendingQuestion | undefined
  let modelOverlay: OverlayHandle | 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 welcome = config.welcome ?? 'ready.'
  let sessionTitle = foldSessionTitle(agent.session.events)?.title
  const header = new HeaderComponent(agent, () => sessionTitle ?? welcome, palette, () => target.current?.model)
  const footer = new FooterComponent(
    agent,
    palette,
    () => toolsExpanded,
    () => showReasoning,
    () => tokens,
    runtime.formatCwd,
    () => target.current?.model,
    () => contextWindow === undefined
      ? undefined
      : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)),
    () => runningStartedAt === undefined ? 0 : Math.max(0, Math.floor((now() - runningStartedAt) / 1_000)),
  )
  ui.addChild(header)
  ui.addChild(chat)
  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 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.resolveModelContext(selected.provider, selected.model).then(
        context => ({ kind: 'resolved', contextWindow: 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): void => {
    if (target.current?.provider === selected.provider && target.current.model === selected.model) {
      appendNotice(`Model is already ${targetLabel(selected)}.`)
      return
    }
    target.current = { provider: selected.provider, model: selected.model }
    resolveContextWindow(target.current)
    appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`)
  }

  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
    }
    modelOverlay?.hide()
    modelOverlay = undefined
    const close = (): void => {
      modelOverlay?.hide()
      modelOverlay = undefined
      requestRender()
    }
    const dialog = new ModelDialog(
      choices,
      target.current,
      resolved.maxModelOptions,
      palette,
      (selected) => {
        close()
        selectModel(selected)
      },
      close,
    )
    modelOverlay = ui.showOverlay(dialog, {
      width: resolved.modelDialogWidth,
      maxHeight: resolved.modelDialogMaxHeight,
      anchor: 'center',
      margin: 1,
    })
    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 (statusTicker !== undefined) clearInterval(statusTicker)
    statusTicker = undefined
    runningStartedAt = undefined
    runtime.terminal.setProgress(false)
  }

  const setStatus = (status: AgentStatus): void => {
    clearStatus()
    editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text)
    if (status === 'running') {
      runningStartedAt = now()
      statusTicker = setInterval(requestRender, 1_000)
      statusTicker.unref()
      runtime.terminal.setProgress(true)
    }
    requestRender()
  }

  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': {
        const text = displayText(contentText(event.data.content).trim())
        if (text) {
          chat.addChild(new Spacer(1))
          chat.addChild(new UserMessageComponent(text, palette, mdTheme))
          if (options.addHistory) editor.addToHistory(text)
        }
        break
      }
      case 'steering/message': {
        const text = displayText(contentText(event.data.content).trim())
        if (text) {
          chat.addChild(new Spacer(1))
          chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering'))
        }
        break
      }
      case 'context/message': {
        const text = displayText(contentText(event.data.content).trim())
        if (text) {
          const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind
          chat.addChild(new Spacer(1))
          chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0))
          chat.addChild(new Text(palette.muted(text), 1, 0))
        }
        break
      }
      case 'prompt/blocked':
        appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
        break
      case 'assistant/chunk':
        if (options.renderChunks) {
          if (streaming === undefined) {
            streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme)
            chat.addChild(streaming)
          }
          streaming.update(event.data.chunk)
        }
        break
      case 'assistant/message': {
        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 === 'context/message'
        || event.type === 'steering/message'
      if (isSurface && !active.has(event.seq)) continue
      if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
      renderEvent(event, { addHistory: populateHistory, renderChunks: false })
    }
    requestRender()
  }

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

  const rejectQuestion = (pending: PendingQuestion): void => {
    pending.overlay?.hide()
    pending.overlay = undefined
    removeAbortListener(pending)
    pending.reject(new UserInteractionError(
      'ask_user_question was interrupted before the user answered',
      'ASK_ABORTED',
    ))
  }

  const startNextQuestion = (): void => {
    if (activeQuestion !== undefined || disposed) return
    const pending = questionQueue.shift()
    if (pending === undefined) return
    activeQuestion = pending
    const show = (): void => {
      const question = pending.request.questions[pending.index]
      if (question === undefined) {
        activeQuestion = undefined
        removeAbortListener(pending)
        pending.resolve({ answers: pending.answers })
        startNextQuestion()
        return
      }
      const dialog = new QuestionDialog(
        question,
        pending.index + 1,
        pending.request.questions.length,
        pending.request.questions.length - pending.answers.length,
        resolved.maxQuestionOptions,
        palette,
        (selection) => {
          pending.overlay?.hide()
          pending.overlay = undefined
          pending.answers.push({ id: question.id, ...selection })
          pending.index += 1
          show()
        },
        () => {
          activeQuestion = undefined
          rejectQuestion(pending)
          startNextQuestion()
        },
      )
      pending.overlay = ui.showOverlay(dialog, {
        width: resolved.questionDialogWidth,
        maxHeight: resolved.questionDialogMaxHeight,
        anchor: 'bottom-left',
        margin: { bottom: 1 },
      })
      requestRender()
    }
    show()
  }

  const disposeUserInteraction = ctx.userInteraction.registerProvider({
    ask(request) {
      return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
        const pending: PendingQuestion = {
          request,
          index: 0,
          answers: [],
          resolve: resolveAnswer,
          reject,
          overlay: undefined,
          onAbort: () => {
            if (activeQuestion === pending) {
              activeQuestion = undefined
              rejectQuestion(pending)
              startNextQuestion()
              return
            }
            // A non-active pending ask remains in the queue until this listener settles it.
            questionQueue.splice(questionQueue.indexOf(pending), 1)
            rejectQuestion(pending)
          },
        }
        request.signal?.addEventListener('abort', pending.onAbort, { once: true })
        questionQueue.push(pending)
        startNextQuestion()
      })
    },
  })

  const shutdown = (exitProcess: boolean): Promise<void> => {
    shuttingDown ??= (async () => {
      disposed = true
      contextResolution = undefined
      clearStatus()
      modelOverlay?.hide()
      modelOverlay = undefined
      for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
      commandControllers.clear()
      if (activeQuestion !== undefined) {
        const pending = activeQuestion
        activeQuestion = undefined
        rejectQuestion(pending)
      }
      for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
      disposeUserInteraction()
      await runtime.terminal.drainInput(100, 20)
      ui.stop()
      if (exitProcess) runtime.exit(0)
    })()
    return shuttingDown
  }

  const requestExit = (): void => {
    if (agent.status === 'running') {
      agent.cancel({ 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))
    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,
    ].map(line => palette.muted(line)).join('\n'), 1, 0))
    requestRender()
  }

  const refreshCommandAutocomplete = (): void => {
    editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
      ctx.commands.list(agent).map(command => ({
        name: command.name,
        description: command.description,
      })),
      agent.session.header.cwd ?? process.cwd(),
    ))
  }
  const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
  refreshCommandAutocomplete()

  // 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: 'cancel',
      description: 'Cancel the active turn',
      handler: () => {
        if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
        agent.cancel({ kind: 'user' })
        return { kind: 'success', text: 'Cancellation requested.' }
      },
    })
    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: 'exit',
      description: 'Exit after the active turn reaches idle',
      handler: () => { requestExit(); return { kind: 'success' } },
    })
  })

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

  editor.onSubmit = (value: string) => {
    const text = value.trim()
    if (text === '') return
    editor.addToHistory(text)
    editor.setText('')
    if (value.startsWith('/')) {
      runCommand(value)
      return
    }
    if (agent.status === 'disposed') {
      appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
    } else if (agent.status === 'running') {
      agent.steer([{ type: 'text', text }])
    } else {
      agent.send([{ type: 'text', text }])
    }
  }

  const removeInputListener = ui.addInputListener((data) => {
    if (activeQuestion !== undefined || modelOverlay !== undefined) return undefined
    if (matchesKey(data, Key.ctrl('o'))) {
      toggleTools()
      return { consume: true }
    }
    if (matchesKey(data, Key.ctrl('r'))) {
      toggleReasoning()
      return { consume: true }
    }
    if (matchesKey(data, Key.ctrl('l'))) {
      ui.invalidate()
      ui.requestRender(true)
      return { consume: true }
    }
    if (matchesKey(data, Key.escape) && agent.status === 'running') {
      agent.cancel({ 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
    recordEventUsage(tokens, event)
    if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
      rebuildTranscript(false)
      return
    }
    renderEvent(event, { addHistory: false, renderChunks: true })
    requestRender()
  })
  const disposeStatus = ctx.on('agent/status', (subject, status) => {
    if (subject !== agent) return
    setStatus(status)
  })
  const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
    if (subject !== agent) return
    liveErrors.add(`${turn}:${step}`)
    // 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 => {
    removeInputListener()
    disposeCommandChanges()
    disposeSessionEvents()
    disposeStatus()
    disposeError()
    disposeAgent()
    disposeSchemeListener()
    disposeTargetListeners()
  }

  rebuildTranscript(true)
  setStatus(agent.status)
  try {
    ui.start()
  } catch (error: unknown) {
    disposed = true
    detachListeners()
    void commandFiber.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: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
      },
    )
    clearStatus()
    disposeUserInteraction()
    ui.stop()
    throw error
  }

  return {
    async dispose(): Promise<void> {
      detachListeners()
      await shutdown(false)
      await commandFiber.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 @deepseek-ai/dsh-cli-demo for non-interactive runs')
  }
  mountTui(ctx, config, {
    terminal: new ProcessTerminal(),
    exit: code => process.exit(code),
  })
}
/* v8 ignore stop */
