import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage,
  createToolResultMessage,
  ReasoningEffortId,
  type LlmCallConfig,
  type LlmModelReasoningInfo,
  MessageId,
  createMessage,
  freezeMessage,
} from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
  createTuiChat,
  FILE_REFERENCE_PROMPT,
  mountTui,
  renderSkillInvocation,
  TuiPromptService,
  resolveTuiConfig,
  type TuiOverlayHost,
  type TuiOverlaySession,
  type TuiRuntime,
} from '../src/index.ts'
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
import {
  appendAssistant,
  appendUser,
  createTuiTestHarness,
  disposeTuiTestHarness,
  type TuiHarnessOptions,
} from './harness.ts'
import { TestSessionQueryService } from './session-query.ts'

const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
  schema: { type: 'null' },
  render: () => [],
}

class FakeTerminal implements Terminal {
  columns = 88
  rows = 32
  kittyProtocolActive = false
  output = ''
  title = ''
  progress: boolean[] = []
  started = 0
  stopped = 0
  drainInput = vi.fn(() => Promise.resolve())
  private onInput: (data: string) => void = () => {}
  private onResize: () => void = () => {}

  start(onInput: (data: string) => void, onResize: () => void): void {
    this.started += 1
    this.onInput = onInput
    this.onResize = onResize
  }

  stop(): void {
    this.stopped += 1
  }

  write(data: string): void {
    this.output += data
  }

  moveBy(lines: number): void {
    this.output += `[move:${lines}]`
  }

  hideCursor(): void {
    this.output += '[hide]'
  }

  showCursor(): void {
    this.output += '[show]'
  }

  clearLine(): void {
    this.output += '[clear-line]'
  }

  clearFromCursor(): void {
    this.output += '[clear-rest]'
  }

  clearScreen(): void {
    this.output += '[clear-screen]'
  }

  setTitle(title: string): void {
    this.title = title
  }

  setProgress(active: boolean): void {
    this.progress.push(active)
  }

  send(data: string): void {
    this.onInput(data)
  }

  resize(columns: number, rows = this.rows): void {
    this.columns = columns
    this.rows = rows
    this.onResize()
  }
}

async function tick(): Promise<void> {
  await new Promise(resolve => setTimeout(resolve, 25))
}

async function setup(options: TuiHarnessOptions = {}) {
  const terminal = new FakeTerminal()
  const exit = vi.fn()
  // Let the harness default cwd ('/workspace') stand: a checkout-dependent
  // process.cwd() longer than the 88-column fake terminal pushes the footer
  // token counters off-screen and fails their assertions by location.
  const result = await createTuiTestHarness(terminal, exit, options)
  await tick()
  return result
}

async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<void> {
  await disposeTuiTestHarness(setupResult)
}

function provideTokenMeter(ctx: Context): void {
  ctx.provide('tokenMeter', {
    measure() {
      return { totalTokens: 0 }
    },
  } as never)
}

/** Minimal advisory-catalog llm stub for tests composing their own context. */
function provideLlmCatalog(ctx: Context): void {
  ctx.provide('llm', {
    listProviders: () => [],
    listModels: () => Promise.resolve([]),
    resolveModelInfo: (provider: string, model: string) => Promise.resolve({
      provider,
      id: model,
      name: model,
    }),
  } as never)
}

describe('TUI config', () => {
  it('defaults every direct-call TUI option', () => {
    expect(resolveTuiConfig(undefined)).toEqual({
      showReasoning: true,
      maxToolOutputLines: 6,
      maxQuestionOptions: 8,
      maxModelOptions: 8,
      maxResumeOptions: 8,
      questionDialogWidth: 200,
      questionDialogMaxHeight: 20,
      modelDialogWidth: 76,
      modelDialogMaxHeight: 20,
      fileSearchMaxResults: 20,
      fileSearchMaxEntries: 10_000,
      fileSearchExcludedDirectories: ['.git', 'node_modules'],
      showHardwareCursor: false,
      theme: {
        color: true,
        truecolor: false,
        leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}',
        rightPrompt: '${timing}',
        inputPrompt: '${symbol} ${indicator}',
        inputPlaceholder: 'press enter to steer and esc to cancel',
      },
      title: 'DeepSeek Harness',
    })
    expect(resolveTuiConfig({
      showReasoning: false,
      maxToolOutputLines: 2,
      maxQuestionOptions: 3,
      maxModelOptions: 4,
      maxResumeOptions: 5,
      questionDialogWidth: 60,
      questionDialogMaxHeight: 14,
      modelDialogWidth: 64,
      modelDialogMaxHeight: 16,
      fileSearchMaxResults: 7,
      fileSearchMaxEntries: 123,
      fileSearchExcludedDirectories: ['.git', 'generated'],
      showHardwareCursor: true,
      theme: { color: false, truecolor: true },
      title: 'DSH',
    })).toEqual({
      showReasoning: false,
      maxToolOutputLines: 2,
      maxQuestionOptions: 3,
      maxModelOptions: 4,
      maxResumeOptions: 5,
      questionDialogWidth: 60,
      questionDialogMaxHeight: 14,
      modelDialogWidth: 64,
      modelDialogMaxHeight: 16,
      fileSearchMaxResults: 7,
      fileSearchMaxEntries: 123,
      fileSearchExcludedDirectories: ['.git', 'generated'],
      showHardwareCursor: true,
      theme: {
        color: false,
        truecolor: true,
        leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}',
        rightPrompt: '${timing}',
        inputPrompt: '${symbol} ${indicator}',
        inputPlaceholder: 'press enter to steer and esc to cancel',
      },
      title: 'DSH',
    })
  })
})

describe('resume command and /resume', () => {
  const RESUME = 'dsh --resume {session}'
  const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
    ({ version: 0, id: SessionId(id), createdAt, cwd })
  const resumeEvents = (
    title: string,
    provider = 'deepseek',
    time = 100,
    reason: TurnEndReason = { kind: 'completed' },
  ): SessionEvent[] => [
    { type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
    { type: 'user/message', seq: 1, time: time + 1, data: createUserMessage({
      content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' },
    }), surfaceOp: 'append' },
    { type: 'step/start', seq: 2, time: time + 2, data: { turn: 1, step: 1 } },
    { type: 'request/header', seq: 3, time: time + 3, data: { header: { config: { provider, model: 'model-1' } }, reason: 'initial' } },
    { type: 'assistant/message', seq: 4, time: time + 4, data: {
      turn: 1, step: 1,
      message: createMessage({
        role: 'assistant',
        content: [{ type: 'text', text: 'done' }],
        source: {
          kind: 'model',
          ...{ provider, model: 'model-1' },
        },
      }),
    }, surfaceOp: 'append' },
    { type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } },
    { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } },
    { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
  ]

  it('prints the resume command on exit once the session is persisted', async () => {
    const result = await setup({
      cwd: '/workspace',
      config: { resumeCommand: RESUME },
      sessionPersistence: { list: async () => [header('main-session', 1000, '/workspace')] },
    })
    result.terminal.send('/exit')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('To resume this session: dsh --resume main-session')
    expect(result.exit).toHaveBeenCalledWith(0)
    await dispose(result)
  })

  it('omits the exit hint when the session is not yet persisted', async () => {
    const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
    result.terminal.send('/exit')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).not.toContain('To resume this session')
    expect(result.exit).toHaveBeenCalledWith(0)
    await dispose(result)
  })

  it('omits the exit hint when the session listing fails', async () => {
    const result = await setup({
      cwd: '/workspace',
      config: { resumeCommand: RESUME },
      sessionPersistence: { list: () => Promise.reject(new Error('disk gone')) },
    })
    result.terminal.send('/exit')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).not.toContain('To resume this session')
    expect(result.exit).toHaveBeenCalledWith(0)
    await dispose(result)
  })

  it('opens a newest-active-first searchable selector and Esc clears before cancelling', async () => {
    const older = header('older-session', 500, '/workspace')
    const newer = header('newer-session', 2000, '/workspace')
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
    const result = await setup({
      cwd: '/workspace',
      config: { resumeCommand: RESUME },
      handoffResume: handoff,
      sessionPersistence: {
        list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')],
        load: async id => id === newer.id
          ? { meta: newer, events: resumeEvents('Newer product work', 'deepseek', 300) }
          : { meta: older, events: resumeEvents('Older investigation', 'deepseek', 100) },
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    const output = result.terminal.output
    expect(output).toContain('Resume session')
    expect(output).toContain('Newer product work')
    expect(output).toContain('Older investigation')
    expect(output).toContain('current · live')
    expect(output.indexOf('Newer product work')).toBeLessThan(output.indexOf('Older investigation'))
    expect(output).not.toContain('foreign-session')
    result.terminal.send('Older')
    await tick()
    expect(result.terminal.output).toContain('⌕ Older')
    result.terminal.send('\x1b')
    await tick()
    expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
      .not.toContain('⌕ Older')
    result.terminal.send('\x1b')
    await tick()
    expect(handoff).not.toHaveBeenCalled()
    await dispose(result)
  })

  it('handles selector navigation, empty matches, and backspace search edits', async () => {
    const target = header('keyboard-target', 10, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Keyboard target') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('\x1b[B')
    result.terminal.send('\x1b[A')
    result.terminal.send('\t')
    result.terminal.send('zz')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('No session matches this search')
    result.terminal.send('\x7f')
    result.terminal.send('\x7f')
    await tick()
    const cleared = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
    expect(cleared).toContain('⌕ ')
    expect(cleared).not.toContain('zz')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('current session')
    result.terminal.send('\x1b')
    await dispose(result)
  })

  it('sanitizes bracketed-paste terminal controls before storing the search query', async () => {
    const target = header('safe-target', 10, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Safe target') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('\x1b[200~Safe\x1b]0;own')
    result.terminal.send('ed\x07 target\x1b[31m\x1b[201~')
    await tick()
    const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
    expect(rendered).toContain('⌕ Safe target')
    expect(rendered).not.toContain('owned')
    expect(rendered).not.toContain('[31m')
    result.terminal.send('\x1b')
    result.terminal.send('Safe\x1b[200~\x1b[201~ target')
    await tick()
    expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
      .toContain('⌕ Safe target')
    await dispose(result)
  })

  it('pages by the number of candidates that fit the current viewport', async () => {
    const targets = Array.from({ length: 8 }, (_, index) =>
      header(`paged-${index}`, 1000 - index, '/workspace'))
    const result = await setup({
      cwd: '/workspace',
      sessionPersistence: {
        list: async () => targets,
        load: async id => ({
          meta: targets.find(target => target.id === id)!,
          events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek', 1000 - Number(id.slice('paged-'.length)) * 10),
        }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('\x1b[6~')
    await tick()
    const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
    expect(rendered).toContain('❯ Paged 3')
    result.terminal.send('\x1b[5~')
    await tick()
    expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
      .toContain('❯ Untitled session')
    result.terminal.resize(10)
    await tick()
    expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
      .toContain('⌕')
    result.terminal.send('\x03')
    await dispose(result)
  })

  it('clips candidate count through the configured visible-session limit', async () => {
    const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')]
    const result = await setup({
      cwd: '/workspace',
      config: { maxResumeOptions: 1 },
      sessionPersistence: {
        list: async () => targets,
        load: async id => ({
          meta: targets.find(target => target.id === id)!,
          events: resumeEvents(`Limited ${id}`),
        }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('(1 of 3)')
    await dispose(result)
  })

  it.each([
    [{ kind: 'aborted' }, 'cancelled'],
    [{ kind: 'error', step: 1, message: 'failed' }, 'error'],
    [{ kind: 'disposed' }, 'disposed'],
    [{ kind: 'max-tokens' }, 'max tokens'],
    [{ kind: 'interrupted' }, 'interrupted'],
    [{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
  ] as const)('renders the last turn result %s', async (reason, label) => {
    const target = header(`turn-${label}`, 10, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek', 100, reason) }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain(`turn 1: ${label}`)
    await dispose(result)
  })

  it('refuses while running instead of cancelling or switching', async () => {
    const result = await setup({ cwd: '/workspace', status: 'running' })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('finish or be cancelled first')
    expect(result.agent.cancelled).toEqual([])
    await dispose(result)
  })

  it('warns when the optional session-query service is absent', async () => {
    const result = await setup({ cwd: '/workspace', mountSessionQuery: false })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('session query is not mounted')
    await dispose(result)
  })

  it('keeps persisted query records readable without a persistence service', async () => {
    const target = header('query-only-persisted', 10, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.provide('sessionQuery', {
          listSessions: () => Promise.resolve([{
            header: target,
            live: false,
            persisted: true,
          }]),
          readSession: () => Promise.resolve({
            session: target,
            events: resumeEvents('Query-only persisted session'),
          }),
        } as never)
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('Query-only persisted session')
    expect(result.terminal.output).toContain('persisted')
    expect(result.terminal.output).not.toContain('session cannot be loaded')
    await dispose(result)
  })

  it('contains a session-query scan failure in the current TUI', async () => {
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.provide('sessionQuery', {
          listSessions: () => Promise.reject(new Error('index unavailable')),
        } as never)
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Resume session scan failed: index unavailable')
    expect(result.terminal.stopped).toBe(0)
    await dispose(result)
  })

  it('supersedes a slower prior selector scan', async () => {
    const first = Promise.withResolvers<SessionRecord[]>()
    let calls = 0
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.provide('sessionQuery', {
          listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]),
        } as never)
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick()
    first.reject(new Error('superseded scan failed'))
    await tick()
    expect(calls).toBe(2)
    expect(result.terminal.output).toContain('No matching sessions')
    expect(result.terminal.output).not.toContain('superseded scan failed')
    result.terminal.send('\x1b[A')
    result.terminal.send('\x1b[B')
    await dispose(result)
  })

  it('drops a selector scan that resolves after TUI disposal', async () => {
    const listing = Promise.withResolvers<SessionRecord[]>()
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.provide('sessionQuery', { listSessions: () => listing.promise } as never)
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick()
    await dispose(result)
    listing.resolve([])
    await tick()
    expect(result.terminal.stopped).toBeGreaterThan(0)
  })

  it('drops loaded selector summaries when the TUI disposed during log reads', async () => {
    const target = header('dispose-during-load', 10, '/workspace')
    const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
    const result = await setup({
      cwd: '/workspace',
      sessionPersistence: {
        list: async () => [target],
        load: () => loading.promise,
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick()
    await dispose(result)
    loading.resolve({ meta: target, events: resumeEvents('Disposed load') })
    await tick()
    expect(result.terminal.stopped).toBeGreaterThan(0)
  })

  it('preflights route availability and corrupt sessions without losing the current TUI', async () => {
    const missing = header('missing-route', 10, '/workspace')
    const corrupt = header('corrupt', 30, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      config: { resumeCommand: RESUME },
      sessionPersistence: {
        list: async () => [missing, corrupt],
        load: async (id) => {
          if (id === corrupt.id) throw new Error('checksum mismatch')
          return {
            meta: missing,
            events: resumeEvents('Missing adapter', 'absent-provider'),
          }
        },
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('Missing adapter')
    expect(result.terminal.output).toContain('absent-provider/model-1')
    expect(result.terminal.output).toContain('Unreadable session')
    result.terminal.send('Missing adapter')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('route is currently unavailable')
    expect(result.terminal.stopped).toBe(0)
    await dispose(result)
  })

  it('keeps a session already live in this runtime visible but disabled', async () => {
    const target = header('live-target', 10, '/workspace')
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
    const result = await setup({
      cwd: '/workspace',
      handoffResume: handoff,
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.provide('sessionQuery', {
          listSessions: () => Promise.resolve([{
            header: target,
            live: true,
            persisted: true,
          }]),
          readSession: () => Promise.resolve({
            session: target,
            events: resumeEvents('Live target'),
          }),
        } as never)
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Live target')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('session is already live in this runtime')
    expect(handoff).not.toHaveBeenCalled()
    await dispose(result)
  })

  it('falls back to assistant provenance and header creation time for sparse logs', async () => {
    const assistantOnly = header('assistant-route', 20, '/workspace')
    const empty = header('empty-log', 10, '/workspace')
    const events = resumeEvents('Assistant route', 'deepseek')
      .filter(event => event.type !== 'request/header')
      .map((event, seq) => ({ ...event, seq })) as SessionEvent[]
    const result = await setup({
      cwd: '/workspace',
      sessionPersistence: {
        list: async () => [assistantOnly, empty],
        load: async id => id === assistantOnly.id
          ? { meta: assistantOnly, events }
          : { meta: empty, events: [] },
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('deepseek/model-1')
    expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString())
    await dispose(result)
  })

  it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => {
    const target = header('target-session', 10, '/workspace')
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process')))
    const result = await setup({
      cwd: '/workspace',
      handoffResume: handoff,
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Target session') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Target session')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(handoff).toHaveBeenCalledTimes(1)
    expect(handoff).toHaveBeenCalledWith(target.id)
    expect(result.terminal.stopped).toBeGreaterThan(0)
    expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
    await dispose(result)
  })

  it('restores the UI when a host returns instead of replacing the process', async () => {
    const target = header('returning-host', 10, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      handoffResume: async () => undefined as never,
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Returning host') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Returning host')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('resume host returned without replacing the process')
    await dispose(result)
  })

  it('keeps the current TUI when the selected log fails its second preflight load', async () => {
    const target = header('racing-corruption', 10, '/workspace')
    let loads = 0
    const result = await setup({
      cwd: '/workspace',
      handoffResume: vi.fn(),
      sessionPersistence: {
        list: async () => [target],
        load: async () => {
          if (++loads > 1) throw new Error('log changed during selection')
          return { meta: target, events: resumeEvents('Racing corruption') }
        },
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Racing corruption')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('Resume failed: session cannot be loaded: failed to inspect session')
    expect(result.terminal.output).toContain('log changed during selection')
    expect(result.terminal.stopped).toBe(0)
    await dispose(result)
  })

  it('does not flush or hand off when disposal begins during selected-session preflight', async () => {
    const target = header('dispose-during-preflight', 10, '/workspace')
    const secondListing = Promise.withResolvers<SessionRecord[]>()
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
    const flush = vi.fn()
    let listings = 0
    const record: SessionRecord = { header: target, live: false, persisted: true }
    const result = await setup({
      cwd: '/workspace',
      handoffResume: handoff,
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.on('session/flush', flush)
        ctx.provide('sessionQuery', {
          listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise,
          readSession: () => Promise.resolve({
            session: target,
            events: resumeEvents('Dispose during preflight'),
          }),
        } as never)
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick()
    result.terminal.send('Dispose during preflight')
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(listings).toBe(2) })
    await dispose(result)
    secondListing.resolve([record])
    await tick()
    expect(flush).not.toHaveBeenCalled()
    expect(handoff).not.toHaveBeenCalled()
  })

  it('hands off a validated session exposed by a query backend without a persistence service', async () => {
    const target = header('query-without-persistence', 10, '/workspace')
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(
      () => Promise.reject(new Error('test host retained process')),
    )
    const result = await setup({
      cwd: '/workspace',
      handoffResume: handoff,
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.provide('sessionQuery', {
          listSessions: () => Promise.resolve([{
            header: target,
            live: false,
            persisted: true,
          }]),
          readSession: () => Promise.resolve({
            session: target,
            events: resumeEvents('Query without persistence'),
          }),
        } as never)
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Query without persistence')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(handoff).toHaveBeenCalledWith(target.id)
    expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
    await dispose(result)
  })

  it('does not hand off after disposal begins during the current-session flush', async () => {
    const target = header('dispose-during-flush', 10, '/workspace')
    const flushing = Promise.withResolvers<undefined>()
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
    const result = await setup({
      cwd: '/workspace',
      handoffResume: handoff,
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.on('session/flush', () => flushing.promise)
      },
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Dispose during flush')
    result.terminal.send('\r')
    await tick()
    const disposing = dispose(result)
    await tick()
    flushing.resolve(undefined)
    await disposing
    expect(handoff).not.toHaveBeenCalled()
  })

  it('does not hand off after disposal begins while terminal input drains', async () => {
    const target = header('dispose-during-drain', 10, '/workspace')
    const draining = Promise.withResolvers<undefined>()
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
    const result = await setup({
      cwd: '/workspace',
      handoffResume: handoff,
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }),
      },
    })
    result.terminal.drainInput.mockImplementationOnce(() => draining.promise)
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Dispose during drain')
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(result.terminal.drainInput).toHaveBeenCalled() })
    await dispose(result)
    draining.resolve(undefined)
    await tick()
    expect(handoff).not.toHaveBeenCalled()
  })

  it('does not restart the terminal when a pending host rejects during disposal', async () => {
    const target = header('host-rejects-during-disposal', 10, '/workspace')
    const host = Promise.withResolvers<never>()
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => host.promise)
    const result = await setup({
      cwd: '/workspace',
      handoffResume: handoff,
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Host disposal') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Host disposal')
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(handoff).toHaveBeenCalled() })
    const startsBeforeDispose = result.terminal.started
    await dispose(result)
    host.reject(new Error('host rejected after disposal'))
    await tick()
    expect(result.terminal.started).toBe(startsBeforeDispose)
    expect(result.terminal.output).not.toContain('host rejected after disposal')
  })

  it('rejects a candidate whose cwd changes between listing and preflight', async () => {
    const target = header('moving-workspace', 10, '/workspace')
    let listings = 0
    const result = await setup({
      cwd: '/workspace',
      handoffResume: vi.fn(),
      sessionPersistence: {
        list: async () => [++listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere')],
        load: async () => ({
          meta: listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere'),
          events: resumeEvents('Moving workspace'),
        }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Moving workspace')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('different workspace')
    await dispose(result)
  })

  it('admits only one handoff while the selected preflight is pending', async () => {
    const target = header('single-handoff', 10, '/workspace')
    const preflight = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
    let loads = 0
    const result = await setup({
      cwd: '/workspace',
      sessionPersistence: {
        list: async () => [target],
        load: () => ++loads === 1
          ? Promise.resolve({ meta: target, events: resumeEvents('Single handoff') })
          : preflight.promise,
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Single handoff')
    result.terminal.send('\r')
    result.terminal.send('\r')
    await tick()
    preflight.resolve({ meta: target, events: resumeEvents('Single handoff') })
    await tick(); await tick()
    expect(loads).toBe(2)
    await dispose(result)
  })

  it('rechecks running state and candidate existence before loading the selected log', async () => {
    const target = header('preflight-races', 10, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      handoffResume: vi.fn(),
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Preflight races') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.agent.status = 'running'
    result.terminal.send('Preflight races')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
    result.agent.status = 'idle'
    await dispose(result)

    let disappearingLists = 0
    const disappearing = await setup({
      cwd: '/workspace',
      handoffResume: vi.fn(),
      sessionPersistence: {
        list: async () => ++disappearingLists <= 2 ? [target] : [],
        load: async () => ({ meta: target, events: resumeEvents('Disappearing target') }),
      },
    })
    disappearing.terminal.send('/resume')
    disappearing.terminal.send('\r')
    await tick(); await tick()
    disappearing.terminal.send('Disappearing target')
    disappearing.terminal.send('\r')
    await tick()
    expect(disappearing.terminal.output).toContain('is no longer available')
    await dispose(disappearing)
  })

  it('rechecks idleness after the selected log finishes loading', async () => {
    const target = header('load-turns-running', 10, '/workspace')
    let loads = 0
    const result = await setup({
      cwd: '/workspace',
      handoffResume: vi.fn(),
      sessionPersistence: {
        list: async () => [target],
        load: async () => {
          loads += 1
          if (loads === 2) result.agent.status = 'running'
          return { meta: target, events: resumeEvents('Load turns running') }
        },
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Load turns running')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
    result.agent.status = 'idle'
    await dispose(result)
  })

  it('keeps resumeCommand as a displayed fallback when the host cannot hand off', async () => {
    const target = header('fallback-session', 10, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      config: { resumeCommand: RESUME },
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Fallback target') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Fallback target')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
    })
    expect(result.terminal.output).toContain('dsh --resume fallback-session')
    expect(result.terminal.stopped).toBe(0)
    await dispose(result)
  })

  it('keeps the selector independent from an absent command fallback', async () => {
    const target = header('no-fallback-session', 10, '/workspace')
    const result = await setup({
      cwd: '/workspace',
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('No fallback target') }),
      },
    })
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('No fallback target')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place')
    })
    await dispose(result)
  })

  it('rechecks idleness after the current-session flush', async () => {
    const target = header('post-flush-running', 10, '/workspace')
    const control: { setRunning?: () => void } = {}
    const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
    const result = await setup({
      cwd: '/workspace',
      handoffResume: handoff,
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.on('session/flush', () => { control.setRunning?.() })
      },
      sessionPersistence: {
        list: async () => [target],
        load: async () => ({ meta: target, events: resumeEvents('Post-flush running') }),
      },
    })
    control.setRunning = () => { result.agent.status = 'running' }
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    result.terminal.send('Post-flush running')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
    expect(handoff).not.toHaveBeenCalled()
    result.agent.status = 'idle'
    await dispose(result)
  })
})

describe('pi-tui chat lifecycle and transcript', () => {
  it('restores durable goal phase without implying automatic continuation', async () => {
    const change: GoalSnapshotChangeMeta = {
      kind: 'goal/change',
      version: GOAL_CHANGE_VERSION,
      operation: 'create',
      goal: {
        id: GoalId('restored-goal'),
        revision: 1,
        objective: 'Resume only with human confirmation',
        phase: 'active',
        maxGoalRounds: 4,
      },
      roundsStarted: 0,
      createdAt: 10,
      updatedAt: 10,
    }
    const result = await setup({
      beforeMount(session) {
        session.append('user/message', createUserMessage({
          content: renderGoalChange(change),
          source: {
            kind: 'goal',
            goalId: change.goal.id,
            revision: change.goal.revision,
            round: 0,
            change,
          },
        }), { surfaceOp: 'append' })
      },
    })
    expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
    expect(result.terminal.output).toContain('/goal resume')
    result.terminal.send('/resume')
    result.terminal.send('\r')
    await tick(); await tick()
    expect(result.terminal.output).toContain('goal active')
    await dispose(result)
  })

  it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
    const result = await setup({
      // A fixed short cwd keeps the footer's token counters inside the 88-column
      // fake terminal regardless of where the checkout lives; cwd rendering has
      // its own dedicated variants test below.
      cwd: '/workspace',
      beforeMount(session) {
        session.append('session/title', {
          title: 'Restored session title',
          messageSeqs: [1],
          source: { kind: 'fallback' },
        })
      },
    })

    expect(result.terminal.title).toBe('Restored session title — DeepSeek Harness')
    expect(result.terminal.output).toContain('Restored session title')
    expect(result.terminal.output).not.toContain('Coding agent ready.')

    result.session.append('session/title', {
      title: 'Live title \u001B]0;unsafe\u0007',
      messageSeqs: [1, 5],
      source: { kind: 'fallback' },
    })
    await tick()

    expect(result.terminal.title).toContain('Live title \\x1b]0;unsafe\\x07 — DeepSeek Harness')
    expect(result.terminal.title).not.toContain('\u001B')
    expect(result.terminal.output).toContain('Live title \\x1b]0;unsafe\\x07')
    await dispose(result)
  })

  it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
    let now = 0
    const result = await setup({
      contextWindow: 100,
      contextTokens: 42,
      // Short cwd: the footer clips its right (context/tools) segment first,
      // and the default worktree path would swallow it at 88 columns.
      cwd: '/opt',
      now: () => now,
      beforeMount(session) {
        appendUser(session, 'restored prompt')
        appendAssistant(session, [
          { type: 'reasoning', text: 'restored thought' },
          { type: 'text', text: '**restored answer**' },
        ], { inputTokens: 1_250, outputTokens: 42 })
        session.append('todo/write', {
          todos: [
            { content: 'read code', status: 'completed' },
            { content: 'write tests', status: 'in_progress' },
            { content: 'ship', status: 'pending' },
          ],
        })
      },
    })

    expect(result.terminal.started).toBe(1)
    expect(result.terminal.title).toBe('DeepSeek Harness')
    expect(result.terminal.output).toContain('DEEPSEEK')
    expect(result.terminal.output).toContain('Coding agent ready.')
    expect(result.terminal.output).toContain('restored prompt')
    expect(result.terminal.output).toContain('restored thought')
    expect(result.terminal.output).toContain('restored answer')
    expect(result.terminal.output).toContain('write tests')
    expect(result.terminal.output).toContain('/opt (tui-staging)  deepseek-v4-flash  ↑1.3k ↓42')
    expect(result.terminal.output).toContain('dsh > ')
    expect(result.terminal.output).not.toContain('main-session  deepseek-v4-flash')
    // Context resolution is async (resolveModelContext); settle before reading.
    await tick()
    expect(result.terminal.output).toContain('42% context')
    expect(result.terminal.output).not.toContain('tools:collapsed')
    // Narrow terminals clip the right-hand context/tools segment first; the
    // model-led left segment stays.
    result.terminal.resize(52)
    await tick()
    expect(result.terminal.output).toContain('deepseek-v4-flash')
    result.terminal.resize(88)
    await tick()

    result.agent.status = 'running'
    agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
    now = 8_000
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: '   ' }], source: { kind: 'user' },
    }), { surfaceOp: 'append' })
    result.session.append('steering/message', {
      turn: 2,
      message: createUserMessage({
        content: [{ type: 'text', text: 'steering note' }],
        source: { kind: 'user' },
      }),
    }, { surfaceOp: 'append' })
    result.session.append('steering/message', {
      turn: 2,
      message: createUserMessage({
        content: [{ type: 'text', text: '' }],
        source: { kind: 'user' },
      }),
    }, { surfaceOp: 'append' })
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' },
    }), { surfaceOp: 'append' })
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender XML context clearly.\n</system-reminder>' }],
      source: { kind: 'plugin', plugin: 'workspace-context' },
    }), { surfaceOp: 'append' })
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: '<system-reminder>&#155;</system-reminder>' }],
      source: { kind: 'plugin', plugin: 'workspace-control-context' },
    }), { surfaceOp: 'append' })
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' },
    }), { surfaceOp: 'append' })
    // A non-plugin injected source (goal) has no `plugin` field, so its context
    // card label falls back to the source kind.
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never,
    }), { surfaceOp: 'append' })
    appendAssistant(result.session, [])
    result.session.append('step/end', { turn: 1, step: 1 })
    result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
    result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
    result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
    result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
    result.session.append('step/start', { turn: 3, step: 1 })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'block-start', index: 1, blockType: 'text' },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'text-delta', index: 1, text: 'live answer' },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
    })
    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
    })
    await tick()
    expect(result.terminal.output).toContain('live thought')
    result.terminal.send('\x12')
    await tick()
    appendAssistant(
      result.session,
      [{ type: 'text', text: 'final live answer' }],
      { inputTokens: 500, outputTokens: 8 },
      { turn: 3, step: 1 },
    )
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('final live answer')
    })

    expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
    expect(result.terminal.output).toContain('Steering')
    expect(result.terminal.output).toContain('user context')
    expect(result.terminal.output).toContain('Context · workspace-context')
    expect(result.terminal.output).toContain('system-reminder')
    expect(result.terminal.output).toContain('Additional instructions from: nested/AGENTS.md')
    expect(result.terminal.output).not.toContain('<system-reminder>')
    expect(result.terminal.output).toContain('\\x9b')
    expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind
    expect(result.terminal.output).toContain('Turn cancelled')
    expect(result.terminal.progress).toContain(true)

    result.session.append('assistant/chunk', {
      turn: 3,
      step: 1,
      chunk: { type: 'text-delta', index: 0, text: 'cleared stream' },
    })
    result.terminal.send('/clear')
    result.terminal.send('\r')
    await tick() // the executor logs command/run durably before the handler clears
    appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }], undefined, { turn: 3, step: 1 })
    await tick()
    expect(result.terminal.output).toContain('answer after clear')

    result.agent.status = 'idle'
    agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
    await tick()
    expect(result.terminal.output).toContain('↑1.8k ↓50')
    expect(result.terminal.output).toContain('deepseek-v4-flash')
    expect(result.terminal.progress.at(-1)).toBe(false)
    await dispose(result)
    expect(result.terminal.stopped).toBe(1)
    expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20)
  })

  it('counts failed and recovered request usage once per step', async () => {
    const result = await setup()
    result.session.append('assistant/chunk', {
      turn: 1,
      step: 1,
      chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 2 } },
    })
    result.session.append('llm/retry', {
      turn: 1,
      step: 1,
      provider: 'mock',
      mode: 'normal',
      policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]',
      retry: 1,
      maxRetries: 2,
      delayMs: 500,
      failure: { message: 'temporary', code: 'SERVER' },
    })
    result.session.append('assistant/chunk', {
      turn: 1,
      step: 2,
      chunk: { type: 'usage', usage: { inputTokens: 7, outputTokens: 3 } },
    })
    appendAssistant(
      result.session,
      [{ type: 'text', text: 'recovered' }],
      { inputTokens: 7, outputTokens: 3 },
      { turn: 1, step: 2 },
    )
    await tick()

    expect(result.terminal.output).toContain('↑17 ↓5')
    await dispose(result)
  })

  it('retracts a failed live stream and renders its durable retry status', async () => {
    const result = await setup()
    result.session.append('assistant/chunk', {
      turn: 1,
      step: 1,
      chunk: { type: 'text-delta', index: 0, text: 'discarded partial answer' },
    })
    result.session.append('step/end', { turn: 1, step: 1 })
    result.session.append('llm/retry', {
      turn: 1,
      step: 1,
      provider: 'mock',
      mode: 'normal',
      policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]',
      retry: 1,
      maxRetries: 2,
      delayMs: 500,
      failure: { message: 'rate limited', code: 'RATE_LIMIT', status: 429 },
    })
    result.session.append('llm/retry', {
      turn: 1,
      step: 2,
      provider: 'mock',
      mode: 'normal',
      policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]',
      retry: 2,
      maxRetries: 2,
      delayMs: 1_000,
      failure: { message: 'failed before chunks', code: 'SERVER', status: 503 },
    })
    result.session.append('llm/retry', {
      turn: 1,
      step: 3,
      provider: 'mock',
      mode: 'always',
      policyKey: '["always",1,10000,0]',
      retry: 1,
      delayMs: 2_000,
      failure: { message: 'retry without limit', code: 'AUTH', status: 401 },
    })
    await tick()

    expect(result.terminal.output).toContain('Retrying model request (1/2) in 500ms: rate limited')
    expect(result.terminal.output).toContain('Retrying model request (2/2) in 1000ms: failed before chunks')
    expect(result.terminal.output).not.toContain('discarded partial answer')
    expect(result.terminal.output).toContain('Retrying model request (1/∞) in 2000ms: retry without limit')
    await dispose(result)
  })

  it('badges queued steering on the prompt context timing and clears it as each drains', async () => {
    // Pin a cwd free of the substring under test; the prompt context renders the path.
    const result = await setup({ status: 'running', cwd: '/workspace' })
    // Running with nothing queued: timing appears once in the prompt context and the editor keeps its hint.
    expect(result.terminal.output).toContain('Assistant')
    expect(result.terminal.output).toContain('Model wait 0.0s')
    expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
    expect(result.terminal.output).not.toContain('│')
    expect(result.terminal.output).not.toContain('queued')

    result.terminal.output = ''
    result.terminal.send('x')
    await tick()
    expect(result.terminal.output).not.toContain('press enter to steer and esc to cancel')
    result.terminal.send('\x7f')
    await tick()
    expect(result.terminal.output).toContain('press enter to steer and esc to cancel')

    const submitSteering = (text: string): void => {
      result.terminal.send(text)
      result.terminal.send('\r')
    }
    const drainSteering = (text: string): void => {
      const id = result.agent.steeredIds.shift()
      if (id !== undefined) {
        result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({
          id,
          role: 'user',
          content: [{ type: 'text', text }],
          source: { kind: 'user' },
        }), 'steering')
      }
      result.session.append('steering/message', {
        turn: 1,
        message: createUserMessage({
          content: [{ type: 'text', text }],
          source: { kind: 'user' },
        }),
      }, { surfaceOp: 'append' })
    }

    // A steering queue for a different agent never touches this status line.
    const other = { ...result.agent, id: SessionId('other') } as Agent
    result.terminal.output = ''
    result.ctx.emit('agent/inbox/enqueue', other, freezeMessage({
      id: MessageId('stub'),
      role: 'user',
      content: [{ type: 'text', text: 'elsewhere' }],
      source: { kind: 'user' },
    }), 'queued')
    await tick()
    expect(result.terminal.output).not.toContain('queued')

    // Two steering messages queue while the turn runs.
    submitSteering('first')
    result.terminal.output = ''
    submitSteering('second')
    await tick()
    expect(result.terminal.output).toContain('2 queued')

    // Draining one submitted message decrements the badge.
    result.terminal.output = ''
    drainSteering('first')
    await tick()
    expect(result.terminal.output).toContain('1 queued')
    expect(result.terminal.output).not.toContain('2 queued')

    // Draining the last queued message returns the plain hint.
    result.terminal.output = ''
    drainSteering('second')
    await tick()
    expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
    expect(result.terminal.output).not.toContain('│')
    expect(result.terminal.output).not.toContain('queued')

    // A drain with no matching queued entry is ignored rather than underflowing.
    result.terminal.output = ''
    drainSteering('continuation')
    submitSteering('after')
    await tick()
    expect(result.terminal.output).toContain('1 queued')

    // A steering/message has no inbox identity and therefore cannot consume a
    // pending slot by itself.
    result.terminal.output = ''
    result.session.append('steering/message', {
      turn: 1,
      message: createUserMessage({
        content: [{ type: 'text', text: 'continue: goal not reached' }],
        source: { kind: 'plugin', plugin: 'hooks' },
      }),
    }, { surfaceOp: 'append' })
    await tick()
    expect(result.terminal.output).toContain('1 queued')
    result.terminal.output = ''
    drainSteering('after')
    await tick()
    expect(result.terminal.output).not.toContain('queued')

    // The turn ending resets the badge, so the next running turn starts clean.
    result.agent.status = 'idle'
    result.ctx.emit('agent/status', result.agent, 'idle')
    result.agent.status = 'running'
    result.terminal.output = ''
    result.ctx.emit('agent/status', result.agent, 'running')
    await tick()
    expect(result.terminal.output).not.toContain('│')
    expect(result.terminal.output).not.toContain('queued')

    // A cancellation discards queued steering: the badge clears without drains.
    submitSteering('third')
    submitSteering('fourth')
    await tick()
    expect(result.terminal.output).toContain('2 queued')
    const discarded = result.agent.steeredIds.splice(0).map(id => freezeMessage({
      id,
      role: 'user' as const,
      content: [{ type: 'text' as const, text: 'discarded' }],
      source: { kind: 'user' as const },
    }))
    // Another agent's dequeue/discard, and ones naming no pending id, leave
    // the badge alone.
    result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!, 'steering')
    result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({
      id: MessageId('never-queued'),
      role: 'user',
      content: [{ type: 'text', text: 'x' }],
      source: { kind: 'user' },
    }), 'steering')
    result.ctx.emit('agent/inbox/discard', other, discarded)
    result.ctx.emit('agent/inbox/discard', result.agent, [
      freezeMessage({
        id: MessageId('never-queued'),
        role: 'user',
        content: [{ type: 'text', text: 'x' }],
        source: { kind: 'user' },
      }),
    ])
    await tick()
    expect(result.terminal.output).toContain('2 queued')
    result.terminal.output = ''
    result.ctx.emit('agent/inbox/discard', result.agent, discarded)
    await tick()
    expect(result.terminal.output).not.toContain('queued')

    await dispose(result)
  })

  it('accumulates exclusive timing buckets across a multi-step turn', async () => {
    let clock = 1_700_000_000_000
    const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
    const result = await setup({ status: 'running' })

    clock += 1_000
    result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } })
    clock += 2_000
    result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answering' } })
    clock += 1_000
    result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'reconsidering' } })
    clock += 2_000
    result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'revised' } })
    clock += 3_000
    result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' })
    clock += 4_000
    result.session.append('step/end', { turn: 1, step: 1 })
    result.session.append('step/start', { turn: 1, step: 2 })
    clock += 1_000
    result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } })
    clock += 2_000
    result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'done' } })
    clock += 3_000
    result.terminal.output = ''
    result.session.append('step/end', { turn: 1, step: 2 })
    await tick()

    expect(result.terminal.output).toContain('Model wait 1.0s · Thinking 4.0s · Response 4.0s · Tools 4.0s')
    expect(result.terminal.output).toContain('Model wait 1.0s · Response 3.0s · Completed')
    expect(result.terminal.output).not.toContain('Thinking 0s')
    nowSpy.mockRestore()
    await dispose(result)
  })

  it('rebuilds used subsecond buckets and the durable local completion time', async () => {
    let clock = new Date(2026, 6, 21, 14, 32, 6).getTime()
    const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
    let result: Awaited<ReturnType<typeof setup>> | undefined
    try {
      result = await setup({
        beforeMount(session) {
          clock += 250
          session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'fast' } })
          clock += 500
          session.append('step/end', { turn: 1, step: 1 })
          clock += 86_400_000
        },
      })
      const completed = 'Model wait 0.2s · Response 0.5s · Completed 2026-07-21 14:32:06'
      expect(result.terminal.output).toContain(completed)

      result.terminal.output = ''
      appendUser(result.session, 'rebuild the transcript')
      result.terminal.resize(result.terminal.columns + 1)
      await tick()
      expect(result.terminal.output).toContain(completed)
    } finally {
      if (result !== undefined) await dispose(result)
      nowSpy.mockRestore()
    }
  })

  it('renders completion for a step whose opening event is unavailable', async () => {
    const result = await setup({ omitInitialLifecycle: true })
    result.session.append('step/end', { turn: 1, step: 1 })
    await tick()
    expect(result.terminal.output).toContain('Completed ')
    expect(result.terminal.output).toContain('Assistant')
    expect(result.terminal.output).toContain('Model wait 0.0s · Completed')
    await dispose(result)
  })

  it('does not reuse a completed turn before the next turn starts', async () => {
    let clock = 1_700_000_000_000
    const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
    let result: Awaited<ReturnType<typeof setup>> | undefined
    try {
      result = await setup({
        beforeMount(session) {
          clock += 2_000
          session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'done' } })
          clock += 1_000
          session.append('step/end', { turn: 1, step: 1 })
          session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
        },
      })
      result.agent.status = 'running'
      result.terminal.output = ''
      result.ctx.emit('agent/status', result.agent, 'running')
      await tick()
      expect(result.terminal.output).not.toContain('Model wait')
      expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
      expect(result.terminal.output).not.toContain('│')
      expect(result.terminal.output).not.toContain('Response 1s')

      result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
      result.session.append('step/start', { turn: 2, step: 1 })
      clock += 1_000
      result.terminal.output = ''
      result.session.append('assistant/chunk', { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'next' } })
      await tick()
      expect(result.terminal.output).toContain('Model wait 1.0s')
      expect(result.terminal.output).not.toContain('Model wait 3.0s')
    } finally {
      if (result !== undefined) await dispose(result)
      nowSpy.mockRestore()
    }
  })

  it('starts a running status before lifecycle events arrive', async () => {
    const result = await setup({ status: 'running', omitInitialLifecycle: true })
    expect(result.terminal.output).not.toContain('Model wait')
    await dispose(result)
  })

  it('replaces the prompt caret with a phase-specific status glyph while running', async () => {
    // Hold the clock past the fade-in so the glyph is at full opacity; with
    // color off the settled glyph renders as its bare character.
    let clock = 0
    const result = await setup({ status: 'running', now: () => clock })
    clock = 1_000

    // A space separates `dsh` from the caret slot: the prompt reads
    // `dsh <glyph> ` with the same visible width as the idle `dsh > `, so the
    // cursor never shifts. Assert both the glyph slot and that constant width
    // (color is off in this harness, so output carries no ANSI to strip).
    const promptWidth = (): number => {
      const row = result.terminal.output.split('\n').find(line => line.includes('dsh'))
      if (row === undefined) throw new Error('prompt row not rendered')
      return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
    }

    // Each phase swaps only the glyph character in the same slot at equal width.
    const phaseGlyph: [() => void, string][] = [
      [() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '],
      [() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' } }), 'dsh ● '],
      [() => result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }), 'dsh ⚙ '],
    ]
    let runningWidth: number | undefined
    for (const [drive, expected] of phaseGlyph) {
      result.terminal.output = ''
      drive()
      await tick()
      expect(result.terminal.output).toContain(expected)
      runningWidth ??= promptWidth()
      expect(promptWidth()).toBe(runningWidth)
    }

    // Idle begins a fade-out; once it settles (clock past the fade window) the
    // plain `>` caret returns at the same width — no horizontal shift. The
    // fade-out timer emits intermediate frames, so read the terminal's final
    // rendered prompt row rather than the accumulated stream.
    result.agent.status = 'idle'
    result.ctx.emit('agent/status', result.agent, 'idle')
    clock = 2_000
    await new Promise(resolve => setTimeout(resolve, 150))
    await tick()
    const promptRow = (): string => {
      const rows = result.terminal.output.split(/\r?\n|\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh'))
      return rows.at(-1) ?? ''
    }
    expect(promptRow()).toContain('dsh > ')
    expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u)
    expect(promptWidth()).toBe(runningWidth)

    await dispose(result)
  })

  // Extract the running glyph's interpolated gray channel from a rendered frame.
  const glyphGray = (frame: string): number => {
    const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame)
    if (m === null) throw new Error('frame did not paint a truecolor glyph')
    const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])]
    // Pure gray: equal channels, never the blue-dominant accent.
    expect(r).toBe(g)
    expect(g).toBe(b)
    return r
  }

  it('throbs the running glyph in dim gray, breathing between trough and full without blanking, never accent', async () => {
    let clock = 0
    let chunkIndex = 0
    const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock })
    // A fresh chunk index each frame changes the streamed line, forcing the
    // diffing terminal to repaint the prompt row and re-emit the glyph slot.
    const frameAt = async (t: number): Promise<string> => {
      clock = t
      chunkIndex += 1
      result.terminal.output = ''
      result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: chunkIndex, text: '.' } })
      await tick()
      return result.terminal.output
    }

    // The pulse breathes between the dimmest trough gray and the settled peak
    // and back, never blanking. At phase 0 (t=1400, pulse level 0, past fade-in)
    // the glyph still paints the trough gray, so the breath dims but never
    // disappears — a symmetric bold→dim→bold throb.
    const trough = await frameAt(1_400)
    expect(trough).toMatch(/●/u)
    expect(glyphGray(trough)).toBe(43)
    // Half a period later (t=2100, pulse peak) it paints the brightest gray.
    const peak = await frameAt(2_100)
    expect(glyphGray(peak)).toBe(136)
    // A frame partway up the swell paints a gray strictly between trough and
    // full, and the glyph is never the accent color.
    const rising = await frameAt(1_680)
    const grey = glyphGray(rising)
    expect(grey).toBeGreaterThan(43)
    expect(grey).toBeLessThan(136)
    expect(rising).not.toMatch(/\x1b\[94m●/u)

    await dispose(result)
  })

  it('fades the running glyph out to the plain caret after the turn ends', async () => {
    let clock = 0
    const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock })
    clock = 1_000
    result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } })
    await tick()

    // End the turn: the last glyph fades out over 300 ms rather than vanishing.
    result.agent.status = 'idle'
    result.ctx.emit('agent/status', result.agent, 'idle')
    await tick()
    // Just after the end the glyph still paints a gray, not `>`.
    expect(result.terminal.output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m●/u)
    expect(result.terminal.output).not.toContain('dsh \x1b[90m>')

    // While the clock stays within the fade window the timer keeps ticking
    // without clearing the fade (the not-yet-elapsed branch): the last frame is
    // still the fading glyph, and the plain caret has not returned.
    result.terminal.output = ''
    await new Promise(resolve => setTimeout(resolve, 120))
    const lastPromptRow = result.terminal.output.split(/\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh')).at(-1) ?? ''
    expect(lastPromptRow).not.toContain('dsh \x1b[90m>')

    // Past the fade window the fade timer clears and the plain caret returns.
    clock = 2_000
    result.terminal.output = ''
    await new Promise(resolve => setTimeout(resolve, 120))
    await tick()
    expect(result.terminal.output).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u)
    expect(result.terminal.output).toContain('>')

    await dispose(result)
  })

  it('appears past the fade midpoint and disappears without truecolor, still dim not accent', async () => {
    let clock = 0
    const result = await setup({ status: 'running', config: { theme: { color: true } }, now: () => clock })
    const frameAt = async (t: number): Promise<string> => {
      clock = t
      result.terminal.output = ''
      result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } })
      await tick()
      return result.terminal.output
    }

    // Without truecolor there is no per-frame gray: below the fade midpoint the
    // glyph slot is blank; past it the glyph shows in the palette muted role
    // (ANSI 90), never the accent (SGR 94).
    const early = await frameAt(60)
    expect(early).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u)
    const shown = await frameAt(300)
    expect(shown).toMatch(/\x1b\[90m●/u)
    expect(shown).not.toMatch(/\x1b\[94m●/u)

    await dispose(result)
  })

  it('shows the plain prompt caret while idle', async () => {
    const result = await setup({ now: () => 0 })
    expect(result.terminal.output).toContain('dsh > ')
    expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u)
    await dispose(result)
  })

  it('escapes configured prompt controls while preserving registry-owned styling', async () => {
    const result = await setup({ config: { theme: { leftPrompt: 'LEFT\u001B]2;unsafe\u0007 ${custom}' } } })
    result.ctx.tuiPrompt.register('custom', '\u001B[1mTRUSTED\u001B[22m')
    await tick()
    expect(result.terminal.output).toContain('LEFT\\x1b]2;unsafe\\x07')
    expect(result.terminal.output).toContain('\u001B[1mTRUSTED\u001B[22m')
    expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007')
    await dispose(result)
  })

  it('redraws when an out-of-band prompt value changes on its own schedule', async () => {
    // A plugin-owned value that changes without any other UI event must still
    // repaint: the registry notifies the renderer through its subscription.
    const result = await setup({ config: { theme: { leftPrompt: '${custom}${model}' } } })
    const handle = result.ctx.tuiPrompt.register('custom', 'BEFORE ')
    await tick()
    expect(result.terminal.output).toContain('BEFORE ')

    result.terminal.output = ''
    handle.set('AFTER ')
    // The coalesced notification lands on a microtask; no session/agent event fires.
    await tick()
    expect(result.terminal.output).toContain('AFTER ')
    expect(result.terminal.output).not.toContain('BEFORE ')
    await dispose(result)
  })

  it('tracks steering drains without a running status line', async () => {
    const result = await setup()
    const source = { kind: 'user' as const }
    result.ctx.emit('agent/inbox/enqueue', result.agent, freezeMessage({
      id: MessageId('stub'),
      role: 'user',
      content: [{ type: 'text', text: 'early' }],
      source,
    }), 'steering')
    result.session.append('steering/message', {
      turn: 1,
      message: createUserMessage({
        content: [{ type: 'text', text: 'early' }],
        source,
      }),
    }, { surfaceOp: 'append' })
    await tick()
    expect(result.terminal.output).not.toContain('queued')
    await dispose(result)
  })

  it('refreshes the running turn timing on its own timer', async () => {
    let now = 1_700_000_000_000
    const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now)
    const intervals = vi.spyOn(globalThis, 'setInterval')
    let result: Awaited<ReturnType<typeof setup>> | undefined
    try {
      result = await setup({ status: 'running', now: () => now })
      // The running prompt animates at ~20 fps (50 ms); the same tick keeps the
      // elapsed timing text current, so no separate timing-only timer exists.
      const refresh = intervals.mock.calls.find(([, interval]) => interval === 50)?.[0]
      if (typeof refresh !== 'function') throw new Error('TUI did not register its running-status refresh interval')
      result.terminal.output = ''
      now += 1_000
      refresh()
      await tick()
      expect(result.terminal.output).toContain('Model wait 1.0s')
    } finally {
      if (result !== undefined) await dispose(result)
      intervals.mockRestore()
      nowSpy.mockRestore()
    }
  })

  it('shows minutes and seconds in accumulated timing', async () => {
    let clock = 1_700_000_000_000
    const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
    const result = await setup({ status: 'running' })
    clock += 95_000
    result.terminal.output = ''
    result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
    await tick()
    expect(result.terminal.output).toContain('Model wait 1m35.0s')
    nowSpy.mockRestore()
    await dispose(result)
  })

  it('trails the completed step timing below the step tool cards, not above them', async () => {
    const clock = new Date(2026, 6, 21, 12, 0, 0).getTime()
    const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(clock)
    const result = await setup({ status: 'running' })
    // A step whose assistant message drives a tool call: the tool card is
    // appended after the assistant text, so the timing footer must follow the
    // tool output rather than sit above it (its first message).
    appendAssistant(result.session, [
      { type: 'text', text: 'Running a command' },
      { type: 'tool-call', id: 'c1' as never, name: 'bash', arguments: '{}' },
    ])
    result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c1' as never,
        content: [{ type: 'text', text: 'command output' }],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    result.terminal.output = ''
    result.session.append('step/end', { turn: 1, step: 1 })
    await tick()

    const frame = result.terminal.output
    const toolAt = frame.indexOf('command output')
    const timingAt = frame.indexOf('Completed 2026-07-21 12:00:00')
    expect(toolAt).toBeGreaterThanOrEqual(0)
    expect(timingAt).toBeGreaterThan(toolAt)
    nowSpy.mockRestore()
    await dispose(result)
  })

  it('preserves accumulated timing across a mid-turn color-scheme change', async () => {
    let clock = 1_700_000_000_000
    const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
    const result = await setup({ status: 'running' })
    clock += 1_000
    result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answering' } })
    clock += 4_000
    result.terminal.output = ''
    result.terminal.send('\x1b[?997;2n')
    await tick()
    await tick()
    expect(result.terminal.output).toContain('Model wait 1.0s · Response 4.0s')

    nowSpy.mockRestore()
    await dispose(result)
  })

  it('renders the ANSI palette and every markdown/content style', async () => {
    const result = await setup({
      cwd: '/workspace',
      config: { theme: { color: true } },
      beforeMount(session) {
        session.append('user/message', createUserMessage({
          content: [
            { type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' },
            { type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' },
            { type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] },
            { type: 'future-block' } as never,
            {} as never,
          ],
          source: { kind: 'user' },
        }), { surfaceOp: 'append' })
        appendAssistant(session, [
          { type: 'reasoning', text: 'styled reasoning' },
          { type: 'text', text: 'styled answer\n\n```ts\nconst answer = 42\n```' },
        ], { inputTokens: 2_000_000, outputTokens: 1_500_000 })
        session.append('todo/write', { todos: [
          { content: 'done', status: 'completed' },
          { content: 'active', status: 'in_progress' },
          { content: 'later', status: 'pending' },
        ] })
      },
    })
    result.terminal.send('/')
    await tick()
    result.terminal.send('zz')
    await tick()
    result.terminal.send('\x0c')
    await tick()

    expect(result.terminal.output).toContain('\x1b[')
    expect(result.terminal.output).toContain('Heading')
    expect(result.terminal.output).toContain('nested_tool({})')
    expect(result.terminal.output).toContain('nested result')
    expect(result.terminal.output).toContain('[future-block]')
    expect(result.terminal.output).toContain('[content]')
    expect(result.terminal.output).toContain('\x1b[36mconst answer = 42\x1b[39m')
    expect(result.terminal.output).not.toContain('```')
    expect(result.terminal.output).toContain('↑2.0m ↓1.5m')
    await dispose(result)
  })

  it('suppresses stale replay chunks and does not duplicate editor history on rebuild', async () => {
    const result = await setup({
      beforeMount(session) {
        appendUser(session, 'first prompt')
        appendUser(session, 'second prompt')
        session.append('assistant/chunk', {
          turn: 1,
          step: 1,
          chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
        })
      },
    })

    expect(result.terminal.output).not.toContain('stale partial response')
    result.terminal.send('/reasoning')
    result.terminal.send('\r')
    result.terminal.send('\x1b[A')
    result.terminal.send('\x1b[A')
    result.terminal.send('\x1b[A')
    result.terminal.send('\r')
    expect(result.agent.sent).toEqual([[{ type: 'text', text: 'first prompt' }]])
    await dispose(result)
  })

  it('formats large token totals and cwd variants', async () => {
    const home = homedir()
    const homeResult = await setup({
      cwd: home,
      beforeMount(session) {
        appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 })
      },
    })
    await vi.waitFor(() => {
      expect(homeResult.terminal.output).toContain('~ (tui-staging)  deepseek-v4-flash  ↑25k ↓10k')
    })
    await dispose(homeResult)

    const childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') })
    await vi.waitFor(() => {
      expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
    })
    await dispose(childResult)

    const unsetResult = await setup({ cwd: null })
    await vi.waitFor(() => {
      expect(unsetResult.terminal.output).toContain('cwd unset')
    })
    await dispose(unsetResult)

    const homeParent = resolve(home, '..')
    const parentResult = await setup({ cwd: homeParent })
    await vi.waitFor(() => {
      expect(parentResult.terminal.output).toContain(homeParent)
    })
    await dispose(parentResult)

    const outsideResult = await setup({ cwd: '/opt' })
    await vi.waitFor(() => {
      expect(outsideResult.terminal.output).toContain('/opt')
    })
    await dispose(outsideResult)

    const logicalResult = await setup({
      cwd: '/w',
      formatCwd: cwd => `logical:${cwd}\x1b`,
    })
    await vi.waitFor(() => {
      expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
    })
    await dispose(logicalResult)
  })

  it('shows the session cache hit rate in the footer and updates it live', async () => {
    // Empty session: no input billed yet, so the cache segment is hidden.
    // A cwd without "cache" in it keeps the negative assertion unambiguous.
    const empty = await setup({ cwd: '/opt' })
    expect(empty.terminal.output).toContain('↑0 ↓0')
    expect(empty.terminal.output).not.toContain('cache')
    await dispose(empty)

    const result = await setup({
      // Pin a short cwd so the footer never clips the cache segment: the
      // default is process.cwd(), and a deep worktree path truncates
      // `cache 60%` at the terminal width.
      cwd: '/opt',
      beforeMount(session) {
        // Cold call: 10 billed input tokens, none served from cache.
        appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 })
      },
    })
    expect(result.terminal.output).toContain('cache 0%')

    result.terminal.output = ''
    // Warm call lands live on the next step (same-step usage replaces rather
    // than accumulates): 5 uncached + 30 cache-read + 5 cache-write billed
    // input, so 30 of the 50 total prompt tokens are hits → 60%.
    appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
      inputTokens: 5,
      outputTokens: 5,
      cacheReadTokens: 30,
      cacheWriteTokens: 5,
    }, { turn: 1, step: 2 })
    await tick()
    expect(result.terminal.output).toContain('cache 60%')
    expect(result.terminal.output).not.toContain('cache 0%')
    await dispose(result)
  })

  it('shows detailed session diagnostics while the agent is running', async () => {
    const timestamp = Date.parse('2026-07-22T09:10:11.000Z')
    const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp)
    const result = await setup({
      cwd: '/workspace/status',
      contextWindow: 128_000,
      contextTokens: 42_000,
      config: { showReasoning: false },
      agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
      tools: {
        read: {
          name: 'read', description: 'Read a file', parameters: {},
          output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null,
        },
        write: {
          name: 'write', description: 'Write a file', parameters: {},
          output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null,
        },
      },
      beforeMount(session) {
        session.append('session/title', {
          title: 'Inspect status \u001B]2;unsafe\u0007',
          messageSeqs: [1],
          source: { kind: 'fallback' },
        })
        appendAssistant(session, [{ type: 'text', text: 'measured' }], {
          inputTokens: 1_250,
          outputTokens: 340,
          cacheReadTokens: 3_000,
          cacheWriteTokens: 250,
        })
        session.append('tool/call', {
          turn: 1, step: 1, callId: 'status-call-1' as never, name: 'read', arguments: '{}',
        })
        session.append('tool/call', {
          turn: 1, step: 1, callId: 'status-call-2' as never, name: 'write', arguments: '{}',
        })
      },
    })
    result.ctx.systemPrompt.section({
      name: 'test:status',
      order: 1,
      text: 'Current instructions \u001B]2;prompt-unsafe\u0007',
    })
    result.agent.status = 'running'
    agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
    result.terminal.send('/status')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('Session status')
    })

    expect(result.terminal.output).toContain('Session status')
    expect(result.terminal.output).toContain('main-session')
    expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07')
    expect(result.terminal.output).toContain('/workspace/status')
    expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
    expect(result.terminal.output).toContain('hidden)')
    // 6 domain events + the /status invocation's own command/run (open turn: joined directly).
    expect(result.terminal.output).toContain('running · 7 events · 1 turn · 1 step · 2 tool calls')
    expect(result.terminal.output).toContain('1,250 input + 340 output')
    expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)')
    expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)')
    expect(result.terminal.output).toContain('2026-07-22 09:10:11 UTC')
    expect(result.terminal.output).toContain('System prompt')
    expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.')
    expect(result.terminal.output).toContain('Current instructions \\x1b]2;prompt-unsafe\\x07')
    expect(result.terminal.output).toContain('Registered tools')
    expect(result.terminal.output).toContain('read, write')
    expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007')

    result.terminal.resize(56)
    result.terminal.send('/redraw')
    result.terminal.send('\r')
    await tick()

    await dispose(result)
    dateNow.mockRestore()
  })

  it('labels unavailable status diagnostics without inventing values', async () => {
    const timestamp = Date.parse('2026-07-22T10:11:12.000Z')
    const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp)
    const result = await setup({
      cwd: null,
      omitInitialLifecycle: true,
      contextTokens: 7,
      agentOptions: {},
      catalog: {
        providers: [],
        models: [],
        resolveModelInfo: () => Promise.resolve({}),
      },
    })
    result.terminal.send('/status')
    result.terminal.send('\r')
    await tick()

    expect(result.terminal.output).toContain('untitled')
    expect(result.terminal.output).toContain('unset (effort unset; reasoning blocks shown)')
    // The /status invocation's command/run lands directly on the empty log — no turn wraps it.
    expect(result.terminal.output).toContain('idle · 1 event · 0 turns · 0 steps · 0 tool calls')
    expect(result.terminal.output).toContain('n/a (0 read + 0 write)')
    expect(result.terminal.output).toContain('7 used · capacity unknown')
    expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC')
    expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.')
    expect(result.terminal.output).toContain('(none)')
    await dispose(result)
    dateNow.mockRestore()
  })

  it('/quit exits while idle', async () => {
    const result = await setup()
    result.terminal.send('/quit')
    result.terminal.send('\r')
    await tick()

    expect(result.exit).toHaveBeenCalledWith(0)
    await dispose(result)
  })

  it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
    const result = await setup()

    result.terminal.send('do the work')
    result.terminal.send('\r')
    expect(result.agent.sent).toEqual([[{ type: 'text', text: 'do the work' }]])

    result.terminal.send('   ')
    result.terminal.send('\r')

    result.agent.status = 'running'
    result.ctx.emit('agent/status', result.agent, 'running')
    result.terminal.send('steer it')
    result.terminal.send('\r')
    expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]])

    result.terminal.send('\x1b')
    result.terminal.send('\x04')
    result.terminal.send('\x03')
    result.terminal.send('\x12')
    result.terminal.send('\x0f')
    expect(result.agent.cancelled).toContainEqual({ kind: 'user' })

    result.agent.status = 'idle'
    for (const command of ['/help', '/reasoning', '/tools', '/redraw', '/reload']) {
      result.terminal.send(command)
      result.terminal.send('\r')
      await tick()
    }
    for (const command of ['/clear', '/wat']) {
      result.terminal.send(command)
      result.terminal.send('\r')
      await tick() // /clear's handler runs after the durable command/run append; keep it from wiping the next notice
    }
    result.terminal.send('draft')
    result.terminal.send('\x03')
    result.terminal.send('\x04')
    await tick()

    expect(result.terminal.output).toContain('Keyboard shortcuts')
    expect(result.terminal.output).toContain('Reasoning blocks')
    expect(result.terminal.output).toContain('Tool cards')
    expect(result.terminal.output).toContain('Unknown command')
    // /reload without a Loader in the context degrades to a warning.
    expect(result.terminal.output).toContain('/reload needs the cordis Loader')
    expect(result.exit).toHaveBeenCalledWith(0)

    // The exit above left the TUI disposed (the mocked runtime.exit returns):
    // a message submitted now is refused instead of reaching the agent. The
    // refusal notice lands in the transcript, but the stopped UI no longer
    // paints, so assert the refusal through the agent surface.
    const sentBefore = result.agent.sent.length
    const steeredBefore = result.agent.steered.length
    result.terminal.send('after shutdown')
    result.terminal.send('\r')
    await tick()
    expect(result.agent.sent).toHaveLength(sentBefore)
    expect(result.agent.steered).toHaveLength(steeredBefore)
    await result.controller.dispose()
    await result.ctx.fiber.dispose()

    const ctrlCExit = await setup()
    ctrlCExit.terminal.send('\x03')
    await tick()
    expect(ctrlCExit.exit).toHaveBeenCalledWith(0)
    await ctrlCExit.controller.dispose()
    await ctrlCExit.ctx.fiber.dispose()

  })

  it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
    const sourceId = SessionId('source-session')
    const sourceHeader: SessionHeader = {
      version: 0,
      id: sourceId,
      cwd: '/workspace',
      createdAt: 1,
    }
    const noCwdHeader: SessionHeader = {
      version: 0,
      id: SessionId('no-cwd'),
      createdAt: 2,
    }
    const sourceEvents: SessionEvent[] = [
      {
        type: 'user/message',
        seq: 0,
        time: 1,
        data: createUserMessage({
          content: [{ type: 'text', text: 'source background' }],
          source: { kind: 'user' },
        }),
        surfaceOp: 'append',
      },
      {
        type: 'session/title',
        seq: 1,
        time: 2,
        data: {
          title: 'Source chat',
          messageSeqs: [0],
          source: { kind: 'fallback' },
        },
      },
    ]
    const result = await setup({
      sessionPersistence: {
        list: async () => [noCwdHeader, sourceHeader],
        load: async (id) => {
          if (id === sourceId) return { meta: sourceHeader, events: sourceEvents }
          if (id === noCwdHeader.id) return { meta: noCwdHeader, events: [] }
          throw new Error(`unexpected persisted session ${id}`)
        },
      },
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
      },
    })

    result.terminal.send('@no-cwd')
    await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · no-cwd') })
    expect(result.terminal.output).toContain('(no cwd)')
    result.terminal.send('\x03')

    result.terminal.send('@chat')
    await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · Source chat') })
    expect(result.terminal.output).toContain('source-session')
    result.terminal.send('\t')
    await tick()
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
    expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]])
    // Idle: the snapshot rides the prompt's admission (additionalContexts on
    // the allow decision), not a separate pre-admission inject.
    expect(result.agent.injected).toHaveLength(0)
    const decision = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', result.agent.sentMessages[0]!,
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(decision.kind).toBe('allow')
    expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
      .toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'source-session' }] })

    // The one-shot wrapper detached itself at admission: replaying the
    // waterfall attaches nothing a second time.
    const replay = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', result.agent.sentMessages[0]!,
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()

    const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
    result.agent.status = 'running'
    result.terminal.send(`steer ${mention}`)
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(result.agent.steered).toHaveLength(1) })
    expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
    // Steering bypasses admission, so its snapshot still arrives via inject.
    expect(result.agent.injected).toHaveLength(1)
    await dispose(result)
  })

  it('keeps a referenced prompt on admission when running no longer accepts next-step input', async () => {
    const result = await setup({
      status: 'running',
      acceptsNextStep: false,
      omitInitialLifecycle: true,
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
        const source = ctx.sessions.create(SessionId('admission-src'), {
          meta: { cwd: process.cwd(), createdAt: 1 },
        })
        appendUser(source, 'source background')
        source.append('session/title', {
          title: 'Admission source',
          messageSeqs: [0],
          source: { kind: 'fallback' },
        })
      },
    })

    result.terminal.send(formatSessionReferenceMention({
      sessionId: SessionId('admission-src'),
      label: 'Admission source',
    }))
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })

    expect(result.agent.steered).toHaveLength(0)
    expect(result.agent.injected).toHaveLength(0)
    const decision = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', result.agent.sentMessages[0]!,
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
      .toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'admission-src' }] })
    await dispose(result)
  })

  it('releases the reference-admission wrapper on the ordinary allowed path', async () => {
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
        const source = ctx.sessions.create(SessionId('leak-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
        appendUser(source, 'source background')
      },
    })
    const send = async (): Promise<void> => {
      result.terminal.send('@leak-source')
      await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · leak-source') })
      result.terminal.send('\t')
      await tick()
      result.terminal.send('\r')
    }
    await send()
    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
    await send()
    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })

    // Each wrapper releases on its own identified message's allowed admission.
    // Running each prompt's admission waterfall detaches its wrapper.
    for (const sent of result.agent.sentMessages) {
      await agentEvents(result.ctx, result.agent).waterfall(
        'agent/prompt-submit', sent,
        new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
      )
    }
    // Both wrappers now gone: a discard naming either prompt's content finds
    // no armed listener, and an unrelated admission is untouched. The leak
    // regression: a listener installed after its cleanup already ran would
    // survive every future cleanup.
    result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages[0]!])
    const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', createUserMessage({
        content: [{ type: 'text', text: 'unrelated' }],
        source: { kind: 'user' },
      }),
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
    // Replaying either sent prompt attaches nothing: the one-shot wrappers
    // are gone, not merely spent.
    for (const sent of result.agent.sentMessages) {
      const replay = await agentEvents(result.ctx, result.agent).waterfall(
        'agent/prompt-submit', sent,
        new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
      )
      expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
    }
    await dispose(result)
  })

  it('releases the reference wrapper when enqueue synchronously discards before followup returns', async () => {
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
        const source = ctx.sessions.create(SessionId('sync-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
        appendUser(source, 'source background')
      },
    })
    // Real send() publishes its already identified snapshot, then an enqueue
    // listener may synchronously cancel and discard it before followup()
    // returns that id. This stub reproduces that ordering.
    const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
    result.agent.followup = (input) => {
      result.agent.sent.push(input.content)
      result.agent.sentMessages.push(input)
      const message = freezeMessage({
        id: input.id,
        role: 'user' as const,
        content: structuredClone(input.content),
        source: structuredClone(input.source),
      })
      result.ctx.emit('agent/inbox/enqueue', foreign, message, 'queued')
      result.ctx.emit('agent/inbox/enqueue', result.agent, message, 'queued')
      result.ctx.emit('agent/inbox/discard', result.agent, [message])
      return message.id
    }

    result.terminal.send('@sync-source')
    await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · sync-source') })
    result.terminal.send('\t')
    await tick()
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })

    // The synchronous discard released the listeners before followup()
    // returned the existing id: replaying the prompt's admission attaches no
    // stranded snapshot, and nothing leaks for the TUI lifetime.
    const replay = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', result.agent.sentMessages[0]!,
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
    await dispose(result)
  })

  it('discards the reference snapshot with its blocked or cancelled prompt', async () => {
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
        const source = ctx.sessions.create(SessionId('blocked-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
        appendUser(source, 'source background')
      },
    })
    // A downstream admission hook blocks the prompt: the attached snapshot
    // must be discarded with it, not stranded for the next prompt.
    let blockPrompts = true
    result.ctx.on('agent/prompt-submit', async (_agent, _message, _signal, next) =>
      blockPrompts ? { kind: 'block' as const, reason: 'policy' } : next())

    result.terminal.send('@blocked-source')
    await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
    result.terminal.send('\t')
    await tick()
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })

    const blocked = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', result.agent.sentMessages[0]!,
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(blocked.kind).toBe('block')
    // Nothing entered history and nothing waits for a later prompt: a fresh
    // unrelated admission sees no leftover contexts.
    expect(result.agent.injected).toHaveLength(0)
    blockPrompts = false
    const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', createUserMessage({
        content: [{ type: 'text', text: 'unrelated' }],
        source: { kind: 'user' },
      }),
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()

    // Second referenced prompt, this time dropped by a broad cancel before
    // any admission runs: the discard listener releases the wrapper.
    result.terminal.send('@blocked-source')
    await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
    result.terminal.send('\t')
    await tick()
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
    // A different prompt passing the still-armed wrapper delegates untouched.
    const passthrough = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', createUserMessage({
        content: [{ type: 'text', text: 'different prompt' }],
        source: { kind: 'user' },
      }),
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(passthrough.kind === 'allow' && passthrough.additionalContexts).toBeUndefined()
    // A foreign agent's discard leaves the wrapper armed.
    const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
    result.ctx.emit('agent/inbox/discard', foreign, [result.agent.sentMessages.at(-1)!])
    // An unrelated discard for this agent also leaves the wrapper armed.
    result.ctx.emit('agent/inbox/discard', result.agent, [createUserMessage({
      content: [{ type: 'text', text: 'unrelated discard' }],
      source: { kind: 'user' },
    })])
    result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!])
    await tick()
    // Idempotent: a repeat discard after cleanup is a no-op.
    result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!])
    const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', result.agent.sentMessages.at(-1)!,
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(afterDiscard.kind === 'allow' && afterDiscard.additionalContexts).toBeUndefined()
    await dispose(result)
  })

  it('fuzzy-completes files and directories while sending only the selected path text', async () => {
    const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-completion-'))
    await mkdir(join(cwd, 'src'), { recursive: true })
    await mkdir(join(cwd, 'docs'), { recursive: true })
    await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n')
    await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n')
    await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n')
    const result = await setup({
      cwd,
      tools: {
        read: {
          name: 'read',
          description: 'Read a file.',
          parameters: {},
          output: UNUSED_TOOL_OUTPUT,
          execute: () => Promise.resolve([]),
        },
      },
    })
    try {
      const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
      expect(assembly.sections).toContainEqual({
        name: 'ui:tui-file-reference',
        text: FILE_REFERENCE_PROMPT,
      })

      result.terminal.send('@sfts')
      await vi.waitFor(() => {
        expect(result.terminal.output).toContain('File · source-file.ts')
      })
      expect(result.terminal.output).toContain('src/source-file.ts')
      result.terminal.send('\t')
      await tick()
      result.terminal.send('\r')
      await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
      expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }])

      result.terminal.send('@do')
      await vi.waitFor(() => {
        expect(result.terminal.output).toContain('Folder · docs/')
      })
      result.terminal.send('\t')
      await vi.waitFor(() => {
        expect(result.terminal.output).toContain('File · design notes.md')
      })
      result.terminal.send('\t')
      await vi.waitFor(() => {
        expect(result.terminal.output).toContain('@"docs/design notes.md"')
      })
      result.terminal.send('\r')
      await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
      expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }])

      result.terminal.send('@unsafe')
      await tick()
      expect(result.terminal.output).not.toContain('File · unsafe')
      result.terminal.send('\x03')
    } finally {
      await result.controller.dispose()
      const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
      expect(assembly.sections).not.toContainEqual({
        name: 'ui:tui-file-reference',
        text: FILE_REFERENCE_PROMPT,
      })
      await result.ctx.fiber.dispose()
      await rm(cwd, { recursive: true, force: true })
    }
  })

  it('isolates failed file discovery from editor autocomplete', async () => {
    const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockRejectedValue(new Error('search failed'))
    const result = await setup()
    try {
      result.terminal.send('@failed')
      await vi.waitFor(() => { expect(list).toHaveBeenCalled() })
      await tick()
      expect(result.agent.sent).toEqual([])
    } finally {
      list.mockRestore()
      await dispose(result)
    }
  })

  it('shows file-reference guidance only while read is visible to the agent', async () => {
    const read: ToolDefinition = {
      name: 'read',
      description: 'Read a file.',
      parameters: {},
      output: UNUSED_TOOL_OUTPUT,
      execute: () => Promise.resolve([]),
    }
    let visibility: 'none' | 'global' | 'agent' = 'none'
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', {
          get(name: string, scope?: Agent) {
            if (name !== 'read' || visibility === 'none') return undefined
            return (scope === undefined) === (visibility === 'global') ? read : undefined
          },
        } as never)
      },
    })
    const fileReferenceText = async (): Promise<string | undefined> => {
      const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
      return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text
    }
    try {
      expect(await fileReferenceText()).toBe('')
      visibility = 'global'
      expect(await fileReferenceText()).toBe('')
      visibility = 'agent'
      expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT)
      visibility = 'none'
      expect(await fileReferenceText()).toBe('')
    } finally {
      await dispose(result)
    }
  })

  it('escapes session autocomplete metadata while preserving the referenced session id', async () => {
    const unsafeId = SessionId('evil\x1b\x07\u009b\ns')
    const unsafeCwd = '/x/\x1b\x07\u009b\nf'
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
        const source = ctx.sessions.create(unsafeId, { meta: { cwd: unsafeCwd, createdAt: 1 } })
        appendUser(source, 'safe background')
      },
    })

    result.terminal.send('@evil')
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('Session · evil\\x1b\\x07\\x9b\\x0a')
    })
    expect(result.terminal.output).toContain('/x/\\x1b\\x07\\x9b\\x0af')
    expect(result.terminal.output).not.toContain('evil\x1b\x07')
    expect(result.terminal.output).not.toContain('/x/\x1b\x07')

    result.terminal.send('\t')
    await tick()
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
    expect(result.agent.sent).toEqual([[
      { type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' },
    ]])
    const decision = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/prompt-submit', result.agent.sentMessages[0]!,
      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
    )
    expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
      .toMatchObject({ references: [{ sessionId: unsafeId }] })
    await dispose(result)
  })

  it('falls back cleanly for non-session, empty, failed, and superseded autocomplete requests', async () => {
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
      },
    })
    const originalListCandidates = result.ctx.sessionReferences.listCandidates.bind(result.ctx.sessionReferences)
    const listCandidates = vi.spyOn(result.ctx.sessionReferences, 'listCandidates')

    result.terminal.send('plain')
    result.terminal.send('\t')
    await tick()
    result.terminal.send('\x03')

    result.terminal.send('/he')
    result.terminal.send('\t')
    await tick()
    result.terminal.send('\x03')

    listCandidates.mockRejectedValueOnce(new Error('candidate lookup failed'))
    result.terminal.send('@failed')
    await vi.waitFor(() => { expect(listCandidates).toHaveBeenCalled() })
    result.terminal.send('\x03')

    result.terminal.send('@empty')
    await tick()
    result.terminal.send('\x03')

    let releaseBase: (() => void) | undefined
    const baseSuggestions = vi.spyOn(CombinedAutocompleteProvider.prototype, 'getSuggestions')
      .mockImplementationOnce(async () => {
        await new Promise<void>((resolve) => { releaseBase = resolve })
        return null
      })
    listCandidates.mockResolvedValueOnce([])
    result.terminal.send('@base-slow')
    await vi.waitFor(() => { expect(releaseBase).toBeTypeOf('function') })
    const baseWaitSignal = listCandidates.mock.calls.at(-1)?.[3]
    result.terminal.send('x')
    await vi.waitFor(() => { expect(baseWaitSignal?.aborted).toBe(true) })
    releaseBase?.()
    await tick()
    baseSuggestions.mockRestore()

    let delayedSignal: AbortSignal | undefined
    let delayed = true
    listCandidates.mockImplementation(async (...args) => {
      if (!delayed) return originalListCandidates(...args)
      delayed = false
      delayedSignal = args[3]
      if (delayedSignal === undefined) throw new Error('expected autocomplete cancellation signal')
      await new Promise<void>((_resolve, reject) => {
        delayedSignal?.addEventListener('abort', () => { reject(new Error('superseded')) }, { once: true })
      })
      return []
    })
    result.terminal.send('@slow')
    await vi.waitFor(() => { expect(delayedSignal).toBeDefined() })
    result.terminal.send('x')
    await vi.waitFor(() => { expect(delayedSignal?.aborted).toBe(true) })
    await dispose(result)
  })

  it('keeps failed mention input and renders durable reference contexts as compact cards', async () => {
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
      },
    })
    const missing = formatSessionReferenceMention({ sessionId: SessionId('missing'), label: 'Missing chat' })
    result.terminal.send(`keep ${missing}`)
    result.terminal.send('\r')
    await tick()
    expect(result.agent.sent).toHaveLength(0)
    expect(result.terminal.output).toContain('Session reference failed')
    expect(result.terminal.output).toContain('keep @[')

    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: 'hidden snapshot payload' }],
      source: {
        kind: 'session-reference',
        references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
      } as never,
    }), { surfaceOp: 'append' })
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: 'visible referenced question' }],
      source: { kind: 'user' },
    }), { surfaceOp: 'append' })
    await tick()
    expect(result.terminal.output).toContain('visible referenced question')
    expect(result.terminal.output).toContain('Referenced sessions · Prefixed source (prefixed)')
    expect(result.terminal.output).not.toContain('hidden snapshot payload')

    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: 'hidden steering context' }],
      source: {
        kind: 'session-reference',
        references: [{ sessionId: 'steering-source', label: 'Steering source' }],
      } as never,
    }), { surfaceOp: 'append' })
    result.session.append('steering/message', {
      turn: 1,
      message: createUserMessage({
        content: [{ type: 'text', text: 'visible steering prompt' }],
        source: { kind: 'user' },
      }),
    }, { surfaceOp: 'append' })
    await tick()
    expect(result.terminal.output).toContain('visible steering prompt')
    expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
    expect(result.terminal.output).not.toContain('hidden steering context')

    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: 'secret full snapshot payload' }],
      source: {
        kind: 'session-reference',
        version: 1,
        references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
      } as never,
    }), { surfaceOp: 'append' })
    await tick()
    expect(result.terminal.output).toContain('Referenced sessions · Source (source)')
    expect(result.terminal.output).not.toContain('secret full snapshot payload')

    const invalidCards: [JsonValue, string][] = [
      ['plain-string-source', 'invalid-shape'],
      [{ kind: 'other' }, 'invalid-kind'],
      [{ kind: 'session-reference', references: [null] }, 'invalid-entry'],
      [{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
    ]
    for (const [source, text] of invalidCards) {
      result.session.append('user/message', createUserMessage({
        content: [{ type: 'text', text }],
        source: source as never,
      }), { surfaceOp: 'append' })
    }
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: 'same-label snapshot' }],
      source: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] } as never,
    }), { surfaceOp: 'append' })
    await tick()
    expect(result.terminal.output).toContain('Referenced sessions · same')
    await dispose(result)
  })

  it('reports malformed and unavailable references without enqueueing', async () => {
    const malformed = await setup()
    malformed.terminal.send('use dsh-session:IiJ')
    malformed.terminal.send('\r')
    await tick()
    expect(malformed.agent.sent).toHaveLength(0)
    expect(malformed.terminal.output).toContain('Invalid session reference')
    await dispose(malformed)

    const unavailable = await setup()
    const mention = formatSessionReferenceMention({ sessionId: SessionId('source') })
    unavailable.terminal.send(`use ${mention}`)
    unavailable.terminal.send('\r')
    await tick()
    expect(unavailable.agent.sent).toHaveLength(0)
    expect(unavailable.terminal.output).toContain('Session reference capability unavailable')
    await dispose(unavailable)
  })

  it('clears a retyped successful mention and aborts pending preparation on disposal', async () => {
    const result = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
        ctx.sessions.create(SessionId('source'))
      },
    })
    const mention = formatSessionReferenceMention({ sessionId: SessionId('source') })
    const value = `use ${mention}`
    let release: (() => void) | undefined
    const prepare = vi.spyOn(result.ctx.sessionReferences, 'prepare').mockImplementation(
      (_agent, content) => new Promise((resolve) => {
        release = () => { resolve({ content }) }
      }),
    )
    result.terminal.send(value)
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
    result.terminal.send(value)
    release?.()
    await tick()
    expect(result.agent.sent).toEqual([[{ type: 'text', text: 'use @source' }]])

    let rejectPreparation: (() => void) | undefined
    prepare.mockImplementation(() => new Promise((_resolve, reject) => {
      rejectPreparation = () => { reject(new Error('delayed failure')) }
    }))
    result.terminal.send(value)
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(rejectPreparation).toBeTypeOf('function') })
    result.terminal.send('new draft')
    rejectPreparation?.()
    await tick()
    expect(result.terminal.output).toContain('delayed failure')
    result.terminal.send('\x03')

    let pendingSignal: AbortSignal | undefined
    prepare.mockImplementation((_agent, _content, _references, signal) => new Promise((_resolve, reject) => {
      pendingSignal = signal
      signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
    }))
    result.terminal.send(value)
    result.terminal.send('\r')
    await vi.waitFor(() => { expect(pendingSignal).toBeDefined() })
    await result.controller.dispose()
    expect(pendingSignal?.aborted).toBe(true)
    await tick()
    await result.ctx.fiber.dispose()

    const lateSuccess = await setup({
      async configureContext(ctx) {
        ctx.provide('tools', { get: () => undefined } as never)
        await ctx.plugin(TestSessionQueryService)
        await ctx.plugin(SessionReferenceService)
        ctx.sessions.create(SessionId('source'))
      },
    })
    let resolveAfterDispose: (() => void) | undefined
    const latePrepare = vi.spyOn(lateSuccess.ctx.sessionReferences, 'prepare').mockImplementation(
      (_agent, content) => new Promise((resolve) => {
        resolveAfterDispose = () => { resolve({ content }) }
      }),
    )
    lateSuccess.terminal.send(value)
    lateSuccess.terminal.send('\r')
    await vi.waitFor(() => { expect(latePrepare).toHaveBeenCalledOnce() })
    await lateSuccess.controller.dispose()
    resolveAfterDispose?.()
    await tick()
    expect(lateSuccess.agent.sent).toHaveLength(0)
    await lateSuccess.ctx.fiber.dispose()
  })

  it('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
    const initialContext = Promise.withResolvers<{ contextWindow: number }>()
    let deferInitialContext = true
    const result = await setup({
      agentOptions: { provider: 'alpha', model: 'a1' },
      contextTokens: 50,
      catalog: {
        providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
        models: [
          { provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
          { provider: 'alpha', id: 'shared', name: 'Alpha Shared' },
          { provider: 'beta', id: 'b1', name: 'Beta One' },
          { provider: 'beta', id: 'shared', name: 'Beta Shared' },
        ],
        async resolveModelInfo(provider, model) {
          const shouldDeferContext = provider === 'alpha' && model === 'a1' && deferInitialContext
          if (shouldDeferContext) deferInitialContext = false
          const context = shouldDeferContext
            ? await initialContext.promise
            : { contextWindow: 200 }
          let reasoning: LlmModelReasoningInfo | undefined
          if (model === 'a1') {
            reasoning = {
              efforts: [
                { id: ReasoningEffortId('low'), name: 'Low' },
                { id: ReasoningEffortId('high'), name: 'High' },
              ],
              defaultEffort: ReasoningEffortId('low'),
            }
          } else if (model === 'b1') {
            reasoning = {
              efforts: [
                { id: ReasoningEffortId('high'), name: 'High' },
                { id: ReasoningEffortId('max'), name: 'Max' },
              ],
              defaultEffort: ReasoningEffortId('high'),
            }
          } else if (provider === 'alpha' && model === 'shared') {
            reasoning = {
              efforts: [
                { id: ReasoningEffortId('standard'), name: 'Standard' },
                { id: ReasoningEffortId('ultra'), name: 'Ultra' },
              ],
            }
          }
          return {
            context,
            ...reasoning === undefined ? {} : { reasoning },
          }
        },
      },
    })

    for (const command of ['/model too many model arguments', '/model missing', '/model shared', '/model alpha/a1', '/model alpha a1']) {
      result.terminal.send(command)
      result.terminal.send('\r')
      await tick()
    }
    expect(result.terminal.output).toContain('Usage: /model')
    expect(result.terminal.output).toContain('Unknown model: missing')
    expect(result.terminal.output).toContain('advertised by multiple providers')
    expect(result.terminal.output).toContain('already alpha/a1')

    const firstSelectorOutput = result.terminal.output.length
    result.terminal.send('/model')
    result.terminal.send('\r')
    result.terminal.send('/model')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model')
    })
    result.terminal.send('\x1b')
    await tick()

    const providerDefaultOutput = result.terminal.output.length
    result.terminal.send('/model alpha/shared')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: provider default.')
    })
    result.terminal.send('/model')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Select model')
    })
    expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — provider default')
    result.terminal.send('\x1b[Z')
    await tick()
    expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Standard')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: Standard.')

    const resetDefaultOutput = result.terminal.output.length
    result.terminal.send('/model')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Standard — current')
    })
    result.terminal.send('\x1b[Z')
    await tick()
    expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Ultra — current')
    result.terminal.send('\x1b[Z')
    await tick()
    expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — provider default')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: provider default.')
    const explicitResetSeed: LlmCallConfig = {
      provider: 'beta',
      model: 'b1',
      reasoningEffort: ReasoningEffortId('max'),
    }
    await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
    await expect(agentEvents(result.ctx, result.agent).waterfall(
      'agent/request',
      0,
      0,
      new AbortController().signal,
      () => Promise.resolve(explicitResetSeed),
    )).resolves.toEqual({ provider: 'alpha', model: 'shared' })

    const nonReasoningOutput = result.terminal.output.length
    result.terminal.send('/model')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Select model')
    })
    result.terminal.send('\x1b[B')
    result.terminal.send('\x1b[B')
    result.terminal.send('\x1b[Z')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model selected: beta/shared.')
    result.terminal.send('/model beta/shared')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model is already beta/shared.')
    })
    await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
    result.terminal.send('/model alpha/a1')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Reasoning effort: Low.')
    })
    const inheritedEffort: LlmCallConfig = {
      provider: 'alpha',
      model: 'a1',
      reasoningEffort: ReasoningEffortId('max'),
    }
    await expect(agentEvents(result.ctx, result.agent).waterfall(
      'agent/request',
      0,
      0,
      new AbortController().signal,
      () => Promise.resolve(inheritedEffort),
    )).resolves.toEqual({ provider: 'beta', model: 'shared' })

    result.agent.status = 'running'
    result.terminal.send('/model')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Select model')
    expect(result.terminal.output).toContain('alpha/a1')
    expect(result.terminal.output).toContain('Alpha One — Fast — Low — current')
    expect(result.terminal.output).toContain('Beta One — High')
    result.terminal.send('\x1b[B')
    result.terminal.send('\x1b[B')
    result.terminal.send('\x1b[Z')
    await tick()
    expect(result.terminal.output).toContain('Beta One — Max')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Model selected: beta/b1')
    expect(result.terminal.output).toContain('Reasoning effort: Max.')
    expect(result.agent.sent).toEqual([])
    expect(result.agent.steered).toEqual([])
    initialContext.resolve({ contextWindow: 100 })
    await tick()
    expect(result.terminal.output).not.toContain('50% context')

    result.terminal.send('/model')
    result.terminal.send('\r')
    await tick()
    result.terminal.send('\x1b')
    await tick()
    expect(result.agent.cancelled).not.toContain('cancelled from terminal')
    result.agent.status = 'idle'
    result.ctx.emit('agent/status', result.agent, 'idle')
    await tick()
    expect(result.terminal.output).toContain('b1 max  ')
    expect(result.terminal.output).toContain('25% context')
    expect(result.terminal.output).not.toContain('tools:collapsed')
    result.terminal.send('/status')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('beta/b1 (effort max; reasoning blocks shown)')

    const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
    expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
    const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 }
    const request = await agentEvents(result.ctx, result.agent).waterfall(
      'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
    )
    expect(request).toEqual({
      provider: 'beta',
      model: 'b1',
      reasoningEffort: ReasoningEffortId('max'),
      temperature: 0.2,
    })
    await dispose(result)
  })

  it('restores the logged model, keeps an unlisted current model visible, and reports catalog failures', async () => {
    const resumed = await setup({
      agentOptions: { provider: 'alpha', model: 'configured' },
      catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] },
      beforeMount(session) {
        session.append('request/header', {
          header: {
            config: {
              provider: 'beta',
              model: 'private',
              reasoningEffort: ReasoningEffortId('ultra'),
            },
          },
          reason: 'initial',
        })
      },
    })
    resumed.terminal.send('/model')
    resumed.terminal.send('\r')
    await tick()
    expect(resumed.terminal.output).toContain('Select model')
    expect(resumed.terminal.output).toContain('beta/private')
    expect(resumed.terminal.output).toContain('private — ultra — current')
    resumed.terminal.send('\x1b')
    await tick()
    resumed.terminal.send('/model beta/private')
    resumed.terminal.send('\r')
    await tick()
    expect(resumed.terminal.output).toContain('with reasoning effort ultra')
    await dispose(resumed)

    const resumedDefault = await setup({
      catalog: {
        providers: [{ id: 'alpha', name: 'Alpha' }],
        models: [{ provider: 'alpha', id: 'default', name: 'Default Model' }],
      },
      beforeMount(session) {
        session.append('request/header', {
          header: { config: { provider: 'alpha', model: 'default' } },
          reason: 'initial',
        })
      },
    })
    expect(resumedDefault.terminal.output).toContain('default  ↑0 ↓0')
    await dispose(resumedDefault)

    const unset = await setup({
      agentOptions: {},
      catalog: {
        providers: [{ id: 'alpha', name: 'Alpha' }],
        models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
        resolveModelInfo: () => Promise.resolve({}),
      },
    })
    unset.terminal.send('/model')
    unset.terminal.send('\r')
    await tick()
    unset.terminal.send('\r')
    await tick()
    expect(unset.terminal.output).toContain('Model selected: alpha/a1')
    expect(unset.terminal.output).toContain('a1  ')
    expect(unset.terminal.output).not.toContain('% context')
    await dispose(unset)

    const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
    empty.terminal.send('/model')
    empty.terminal.send('\r')
    await tick()
    expect(empty.terminal.output).toContain('Current model: unset')
    expect(empty.terminal.output).toContain('No models are advertised')
    const assembly = await empty.ctx.systemPrompt.assemble(assembleContextFor(empty.agent))
    expect(assembly.variables).toEqual({})
    const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' }
    await expect(agentEvents(empty.ctx, empty.agent).waterfall(
      'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
    )).resolves.toBe(seed)
    await dispose(empty)

    const failed = await setup({
      catalog: {
        providers: [{ id: 'deepseek', name: 'DeepSeek' }],
        models: [],
        listModels: () => Promise.reject(new Error('catalog offline')),
        resolveModelInfo: () => Promise.reject(new Error('capacity offline')),
      },
    })
    failed.terminal.send('/model')
    failed.terminal.send('\r')
    await vi.waitFor(() => {
      expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
    })
    expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
    await dispose(failed)

    const reasoningFailed = await setup({
      catalog: {
        providers: [{ id: 'deepseek', name: 'DeepSeek' }],
        models: [{ provider: 'deepseek', id: 'model-1', name: 'Model One' }],
        resolveModelInfo: () => Promise.reject(new Error('reasoning metadata offline')),
      },
    })
    reasoningFailed.terminal.send('/model')
    reasoningFailed.terminal.send('\r')
    await vi.waitFor(() => {
      expect(reasoningFailed.terminal.output).toContain('Could not read the model catalog: reasoning metadata offline')
    })
    await dispose(reasoningFailed)
  })

  it('does not render a model catalog that resolves after TUI disposal', async () => {
    const deferred = Promise.withResolvers<never[]>()
    const result = await setup({
      catalog: {
        providers: [{ id: 'deepseek', name: 'DeepSeek' }],
        models: [],
        listModels: () => deferred.promise,
      },
    })
    result.terminal.send('/model')
    result.terminal.send('\r')
    await result.controller.dispose()
    deferred.resolve([])
    await tick()
    expect(result.terminal.output).not.toContain('Available models')
    await result.ctx.fiber.dispose()

    const rejected = Promise.withResolvers<never[]>()
    const rejectedResult = await setup({
      catalog: {
        providers: [{ id: 'deepseek', name: 'DeepSeek' }],
        models: [],
        listModels: () => rejected.promise,
      },
    })
    rejectedResult.terminal.send('/model')
    rejectedResult.terminal.send('\r')
    await rejectedResult.controller.dispose()
    rejected.reject(new Error('late catalog failure'))
    await tick()
    expect(rejectedResult.terminal.output).not.toContain('late catalog failure')
    await rejectedResult.ctx.fiber.dispose()

    const context = Promise.withResolvers<{ contextWindow: number }>()
    const contextResult = await setup({
      contextTokens: 99,
      catalog: {
        providers: [{ id: 'deepseek', name: 'DeepSeek' }],
        models: [],
        resolveModelInfo: () => context.promise.then(value => ({ context: value })),
      },
    })
    await contextResult.controller.dispose()
    context.resolve({ contextWindow: 100 })
    await tick()
    expect(contextResult.terminal.output).not.toContain('99% context')
    await contextResult.ctx.fiber.dispose()
  })

  it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
    const result = await setup()
    const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
      kind: 'success' as const,
      text: `PLUGIN:${rawInput}`,
    }))
    result.ctx.commands.register({
      name: 'plugin-check',
      description: 'Run a plugin command',
      input: { hint: '<value>' },
      handler,
    })
    result.ctx.commands.register({
      name: 'plugin-fail',
      description: 'Fail a plugin command',
      handler: () => { throw new Error('plugin command exploded') },
    })
    result.ctx.commands.register({
      name: 'plugin-error',
      description: 'Return an error result',
      handler: () => ({ kind: 'error' as const, text: 'plugin error result' }),
    })

    result.terminal.send('/plugin-ch')
    await tick()
    expect(result.terminal.output).toContain('<value> — Run a plugin command')
    result.terminal.send('\x03')

    result.terminal.send('/plugin-check  value  ')
    result.terminal.send('\r')
    await tick()

    expect(handler).toHaveBeenCalledTimes(1)
    const invocation = handler.mock.calls[0]?.[0]
    expect(invocation?.agent).toBe(result.agent)
    // pi-tui's Editor owns terminal-line normalization and removes trailing
    // spaces before onSubmit; the registry preserves the adapter-delivered line.
    expect(invocation?.rawInput).toBe('  value')
    expect(result.terminal.output).toContain('PLUGIN:  value')
    result.terminal.send('/plugin-fail')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Command failed: plugin command exploded')
    result.terminal.send('/plugin-error')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('plugin error result')
    result.terminal.send('/help')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('/plugin-check <value> — Run a plugin command')
    expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')

    await result.controller.dispose()
    expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
      'plugin-check',
      'plugin-error',
      'plugin-fail',
    ])
    await result.ctx.fiber.dispose()
  })

  it('aborts an in-flight plugin command during TUI disposal', async () => {
    const result = await setup()
    let started!: () => void
    const ready = new Promise<void>((resolve) => { started = resolve })
    let commandSignal: AbortSignal | undefined
    result.ctx.commands.register({
      name: 'wait-plugin',
      description: 'Wait until disposal',
      handler: ({ signal }) => {
        commandSignal = signal
        started()
        return new Promise((resolve) => {
          signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true })
        })
      },
    })

    result.terminal.send('/wait-plugin')
    result.terminal.send('\r')
    await ready
    await result.controller.dispose()

    expect(commandSignal?.aborted).toBe(true)
    expect(result.terminal.output).not.toContain('late result')
    await result.ctx.fiber.dispose()
  })

  it('suppresses a successful plugin result that settles as TUI disposal starts', async () => {
    const result = await setup()
    let started!: () => void
    const ready = new Promise<void>((resolve) => { started = resolve })
    let resolveCommand!: (result: { kind: 'success'; text: string }) => void
    result.ctx.commands.register({
      name: 'late-success',
      description: 'Resolve while the TUI closes',
      handler: () => new Promise((resolve) => {
        resolveCommand = resolve
        started()
      }),
    })

    result.terminal.send('/late-success')
    result.terminal.send('\r')
    await ready
    resolveCommand({ kind: 'success', text: 'must not render after disposal' })
    // Let the command boundary accept the result before disposal, but leave the
    // TUI continuation queued so the success-side disposal guard owns the race.
    await Promise.resolve()
    await result.controller.dispose()
    await tick()

    expect(result.terminal.output).not.toContain('must not render after disposal')
    await result.ctx.fiber.dispose()
  })

  it('cancels before /quit while running and handles agent errors/disposal', async () => {
    const result = await setup({ status: 'running' })
    result.terminal.send('/quit')
    result.terminal.send('\r')
    await tick()
    expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
    expect(result.exit).toHaveBeenCalledWith(0)

    const events = await setup()
    const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
    const unrelatedAgent = { ...events.agent, id: unrelatedSession.id, session: unrelatedSession }
    unrelatedSession.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
    unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
    agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
    agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error'))
    agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
    agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
    events.session.append('step/end', { turn: 1, step: 1 })
    events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } })
    events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
    events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } })
    events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
    events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted' } })
    events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
    events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
    events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
    events.session.append('turn/end', { turn: 5, reason: { kind: 'interrupted' } })
    events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
    events.session.append('turn/end', {
      turn: 6,
      reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
    })
    events.session.append('turn/start', { turn: 8, trigger: { kind: 'message', source: { kind: 'user' } } })
    events.session.append('turn/end', { turn: 8, reason: { kind: 'disposed' } })
    events.session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
    // Merge-extensible reason kind unknown to the TUI still names the stop.
    events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never })
    agentEvents(events.ctx, events.agent).emit('agent/disposed')
    await tick()
    expect(events.terminal.output).toContain('live failure')
    expect(events.terminal.output).toContain('durable failure')
    expect(events.terminal.output).toContain('Turn cancelled')
    expect(events.terminal.output).toContain('structured provider failure')
    expect(events.terminal.output).toContain('output-token limit')
    expect(events.terminal.output).toContain('previous process ended')
    expect(events.terminal.output).toContain('Turn stopped: the agent was disposed')
    expect(events.terminal.output).toContain('Turn ended: plugin-policy')
    expect(events.terminal.output).toContain('was disposed')
    await dispose(events)
  })

  it('rejects input after the agent is disposed out from under the TUI', async () => {
    const result = await setup()

    // The agent leaves the registry (e.g. an agent-loop-only reload) while the
    // TUI stays mounted. A later send must report disposal, not drive the
    // detached zombie agent.
    agentEvents(result.ctx, result.agent).emit('agent/disposed')
    await tick()
    expect(result.terminal.output).toContain('was disposed')

    result.terminal.send('drive the zombie')
    result.terminal.send('\r')
    await tick()
    expect(result.agent.sent).toHaveLength(0)
    expect(result.agent.steered).toHaveLength(0)
    expect(result.terminal.output).toContain('is disposed')
    await dispose(result)
  })
})

describe('skill slash command', () => {
  const withSkills = async (ctx: Context): Promise<void> => {
    ctx.provide('tools', { get() { return undefined } } as never)
    await ctx.plugin(SkillService)
    const skills = ctx.get('skills')
    if (skills === undefined) throw new Error('skills service not mounted')
    skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
    skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' })
    skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
  }

  it('labels slash completions by scope and hides model-disabled skills', async () => {
    const result = await setup({ configureContext: withSkills })
    result.terminal.send('/skill')
    await tick()
    expect(result.terminal.output).toContain('demo-skill')
    expect(result.terminal.output).toContain('(user)')
    expect(result.terminal.output).toContain('project-skill')
    expect(result.terminal.output).toContain('(project)')
    expect(result.terminal.output).not.toContain('[instructions]')
    expect(result.terminal.output).not.toContain('hidden-skill')
    await dispose(result)
  })

  it('loads a skill as a user turn, appending typed instructions', async () => {
    const result = await setup({ configureContext: withSkills })
    result.terminal.send('/skill:demo-skill')
    result.terminal.send('\r')
    await tick()
    expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>' }]])

    result.agent.status = 'running'
    result.terminal.send('/skill:demo-skill focus on tests')
    result.terminal.send('\r')
    await tick()
    expect(result.agent.steered).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>\n\nfocus on tests' }]])
    await dispose(result)
  })

  it('invokes a model-disabled skill by its exact name', async () => {
    const result = await setup({ configureContext: withSkills })
    result.terminal.send('/skill:hidden-skill')
    result.terminal.send('\r')
    await tick()
    expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
    await dispose(result)
  })

  it('reports an unknown skill and an empty skill name without sending', async () => {
    const result = await setup({ configureContext: withSkills })
    result.terminal.send('/skill:does-not-exist')
    result.terminal.send('\r')
    await tick()
    result.terminal.send('/skill:')
    result.terminal.send('\r')
    await tick()
    // A space right after the colon parses to an empty name, not a name of
    // "focus"; the documented syntax puts the name immediately after the colon.
    result.terminal.send('/skill: focus')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Unknown skill: does-not-exist')
    expect(result.terminal.output).toContain('Usage: /skill:<name>')
    expect(result.agent.sent).toEqual([])
    await dispose(result)
  })

  it('warns when no skill service is mounted', async () => {
    const result = await setup()
    result.terminal.send('/skill:demo-skill')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Skills are not available')
    expect(result.agent.sent).toEqual([])
    await dispose(result)
  })

  it('surfaces skill lookup failures as an error notice', async () => {
    const result = await setup({
      configureContext: async (ctx) => {
        ctx.provide('tools', { get() { return undefined } } as never)
        ctx.provide('skills', {
          list: () => Promise.reject(new Error('list boom')),
          get: () => Promise.reject(new Error('get boom')),
        } as never)
      },
    })
    result.terminal.send('/skill:demo-skill')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('failed to load')
    expect(result.terminal.output).toContain('get boom')
    await dispose(result)
  })

  it('drops skill list and lookup results that settle after disposal', async () => {
    const pendingList: Array<(value: SkillSummary[]) => void> = []
    const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
    const result = await setup({
      configureContext: async (ctx) => {
        ctx.provide('tools', { get() { return undefined } } as never)
        ctx.provide('skills', {
          list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
          get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
        } as never)
      },
    })
    result.terminal.send('/skill:demo-skill')
    result.terminal.send('\r')
    await tick()
    result.terminal.send('/skill:other-skill')
    result.terminal.send('\r')
    await tick()
    await dispose(result)

    for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
    pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
    pendingGet[1]?.reject(new Error('late failure'))
    await tick()
    expect(result.agent.sent).toEqual([])
    expect(result.terminal.output).not.toContain('late failure')
    expect(result.terminal.output).not.toContain('late body')
  })
})

describe('renderSkillInvocation', () => {
  const skill: SkillDefinition = {
    name: 'demo-skill',
    description: 'Demo skill',
    source: 'runtime',
    provider: 'runtime',
    content: 'Body text.',
  }

  it('renders directory, url, opaque, and absent resource bases', () => {
    expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'directory', path: '/skills/demo' } }, '')).toBe(
      '<skill name="demo-skill">\nReferences in this skill are relative to /skills/demo.\n\nBody text.\n</skill>',
    )
    expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'url', url: 'https://x/y' } }, 'go')).toBe(
      '<skill name="demo-skill">\nReferences in this skill are relative to https://x/y.\n\nBody text.\n</skill>\n\ngo',
    )
    expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'opaque', description: 'held in memory' } }, '')).toBe(
      '<skill name="demo-skill">\nheld in memory\n\nBody text.\n</skill>',
    )
    expect(renderSkillInvocation(skill, '')).toBe('<skill name="demo-skill">\nBody text.\n</skill>')
  })

  it('throws on an unknown resource base kind', () => {
    expect(() => renderSkillInvocation({ ...skill, resourceBase: { kind: 'future' } as never }, '')).toThrow('unreachable variant')
  })
})

describe('tool cards and surface replay', () => {
  const tools: Record<string, ToolDefinition> = {
    bash: {
      name: 'bash', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
      presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
    },
    signal: {
      name: 'signal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
      presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
    },
    edit: {
      name: 'edit', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({
        card: 'diff',
        title: 'Edit files',
        diffs: [
          { path: 'a.txt', oldText: 'old', newText: 'new' },
          { path: 'b.txt', oldText: 'before', newText: 'after' },
        ],
      }),
      presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
    },
    singleDiff: {
      name: 'singleDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({
        card: 'diff',
        title: 'Edit src/only.ts',
        diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }],
      }),
    },
    generic: {
      name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
      presentResult: () => ({
        card: 'generic',
        title: 'Inspected',
        content: [{ type: 'text', text: 'result **text**\n\n```console\nstarted background task bash-5\n```' }],
      }),
    },
    throwing: {
      name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => { throw new Error('call presenter boom') },
      presentResult: () => { throw new Error('result presenter boom') },
    },
    rawTerminal: {
      name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'terminal', title: 'raw command' }),
    },
    // An empty-string description is treated as no description: the header omits
    // the ` / <desc>` segment, exactly as an absent description does.
    emptyDescTerminal: {
      name: 'emptyDescTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'terminal', title: 'blank desc command', description: '' }),
    },
    // A generic card whose title only repeats the tool name and carries no
    // content or rawInput renders a header with an empty body block.
    emptyBody: {
      name: 'emptyBody', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'generic', title: 'emptyBody' }),
    },
    multilineTerminal: {
      name: 'multilineTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      // A multi-line bash command as the title/description: the card title and the
      // meta rows are single logical lines and must render inline, not break rows.
      presentCall: () => ({ card: 'terminal', title: 'S=/tmp\necho "$S"', description: 'set\nand echo' }),
    },
    undefinedViews: {
      name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => undefined,
      presentResult: () => undefined,
    },
    empty: {
      name: 'empty', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'generic', title: 'Empty card' }),
    },
    terminalResult: {
      name: 'terminalResult', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
      presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
    },
    symbolic: {
      name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
    },
    knownXml: {
      name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
      presentCall: () => ({ card: 'generic', title: 'Known XML' }),
    },
  }

  it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
    const result = await setup({ tools, config: { maxToolOutputLines: 4 } })
    const calls = [
      ['c1', 'bash', '{"command":"printf hello"}'],
      ['c2', 'signal', '{}'],
      ['c3', 'edit', '{}'],
      ['c4', 'generic', '{}'],
      ['c5', 'throwing', '{}'],
      ['c6', 'unknown', 'not-json'],
      ['c7', 'rawTerminal', '{"value":"raw"}'],
      ['c14', 'emptyDescTerminal', '{}'],
      ['c15', 'emptyBody', '{}'],
      ['c9', 'multilineTerminal', '{}'],
      ['c8', 'undefinedViews', '{"value":8}'],
      ['c10', 'empty', '{}'],
      ['c11', 'terminalResult', '{}'],
      ['c12', 'symbolic', '{}'],
      ['c13', 'knownXml', '{}'],
    ] as const
    appendAssistant(result.session, [
      { type: 'text', text: 'Calling tools' },
      ...calls.map(([id, name, args]) => ({
        type: 'tool-call' as const, id: id as never, name, arguments: args,
      })),
    ])
    for (const [id, name, args] of calls) {
      result.session.append('tool/call', { turn: 1, step: 1, callId: id as never, name, arguments: args })
    }
    await tick()
    expect(result.terminal.output).toContain('$ raw command')
    result.terminal.send('/reasoning')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('call presenter boom')
    expect(result.terminal.output).toContain('Symbol(input)')
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c1' as never,
        content: [{ type: 'text', text: 'raw bash' }],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c2' as never,
        content: [{ type: 'text', text: 'stopped' }],
        isError: true,
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c3' as never,
        content: [{ type: 'text', text: 'done' }],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c4' as never,
        content: [{ type: 'text', text: 'raw generic' }],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c5' as never,
        content: [{ type: 'text', text: 'raw throwing' }],
        isError: false,
      }),
      meta: { value: 1 },
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c7' as never,
        content: [
          { type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
          { type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
          { type: 'future-result' } as never,
        ],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c8' as never,
        content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c11' as never,
        content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'c13' as never,
        content: [{ type: 'text', text: '<known><value>literal</value></known>' }],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/result', {
      turn: 1,
      step: 1,
      message: createToolResultMessage({
        callId: 'orphan' as never,
        content: [{ type: 'text', text: '<result><path>/tmp/a.txt</path><content><line number="1">hello</line><line number="2">world</line></content></result>' }],
        isError: true,
      }),
      error: { name: 'InterruptedError', code: 'interrupted' },
    }, { surfaceOp: 'append' })
    await tick()

    const output = result.terminal.output
    expect(output).toContain('Run command')
    expect(output).toContain('printf hello')
    // A multi-line terminal title and description render inline (newline escaped
    // to `\x0a`), so they cannot break onto extra rows and collide with the body.
    expect(output).toContain('S=/tmp\\x0aecho "$S"')
    expect(output).toContain('set\\x0aand echo')
    expect(output).toContain('lines (Ctrl+O to expand)')
    expect(output).toContain('SIGTERM')
    // The header is a fixed `Tool / <name>` frame; the tool name shows there.
    expect(output).toContain('Tool / bash')
    expect(output).toContain('Tool / edit')
    // An empty-string terminal description contributes no ` / <desc>` segment;
    // the header ends at the tool name, and the command shows as the body $-line.
    expect(output).toContain('Tool / emptyDescTerminal')
    expect(output).not.toContain('Tool / emptyDescTerminal /')
    expect(output).toContain('$ blank desc command')
    // A card whose title only repeats the name renders header-only (empty body).
    expect(output).toContain('Tool / emptyBody')
    // A diff card drops its title (the paths + change footer carry the meaning).
    // The first file's path is head-visible; the second file and the change
    // footer sit past this card's 4-line budget and appear only when expanded.
    expect(output).not.toContain('Edit files')
    expect(output).toContain('a.txt')
    // A generic card's presenter title moves from the header into the body.
    expect(output).toContain('Inspected')
    expect(output).toContain('result text')
    expect(output).toContain('started background task bash-5')
    expect(output).not.toContain('```console')
    expect(output).toContain('Presenter failed')
    expect(output).toContain('not-json')
    expect(output).toContain('nested output')
    expect(output).toContain('[future-result]')
    expect(output).toContain('undefined presenter output')
    expect(output).toContain('Empty card')
    expect(output).toContain('converted terminal')
    expect(output).toContain('<known><value>literal</value></known>')
    expect(output).toContain('path: /tmp/a.txt')
    expect(output).toContain('line (number="1"): hello')
    expect(output).not.toContain('<result>')

    result.terminal.send('/redraw')
    result.terminal.send('\r')
    await tick()
    const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
    expect(collapsed).toContain('Run command')
    expect(collapsed).toContain('[exit 0]')
    expect(collapsed).not.toContain('▌ hello')
    expect(collapsed).not.toContain('▌ world')
    result.terminal.send('\x0f')
    await tick()
    expect(result.terminal.output).toContain('world')
    expect(result.terminal.output).toContain('Tool cards expanded.')
    expect(result.terminal.output).not.toContain('tools:expanded')
    expect(result.terminal.output).toContain('+ created')
    expect(result.terminal.output).toContain('console')
    // The multi-file diff's second-file change and its footer surface once
    // expanded (`+ after` is b.txt's new text; the footer counts both files).
    expect(result.terminal.output).toContain('+ after')
    expect(result.terminal.output).toContain('· 2 files')
    await dispose(result)
  })

  it('names a single-file diff in the body once, under a fixed Tool header', async () => {
    const result = await setup({ tools })
    appendUser(result.session, 'edit one file')
    appendAssistant(result.session, [
      { type: 'text', text: 'Editing' },
      { type: 'tool-call', id: 'single' as never, name: 'singleDiff', arguments: '{}' },
    ])
    result.session.append('tool/call', {
      turn: 1, step: 1, callId: 'single' as never, name: 'singleDiff', arguments: '{}',
    })
    await tick()
    const output = result.terminal.output
    // The header is a fixed `Tool / <name>` frame; the diff title is dropped and
    // the file path shows once in the body, above the change footer.
    expect(output).toContain('Tool / singleDiff')
    expect(output).not.toContain('Edit src/only.ts')
    expect(output.split('src/only.ts').length - 1).toBe(1)
    expect(output).toContain('- old')
    expect(output).toContain('+ new')
    expect(output).toContain('· 1 file')
    await dispose(result)
  })

  it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
    const result = await setup({ tools })
    appendUser(result.session, 'old prompt')
    const assistant = result.session.append('assistant/message', {
      turn: 1,
      step: 1,
      message: createMessage({
        role: 'assistant',
        content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
        source: {
          kind: 'model',
          ...{ provider: 'mock', model: 'deepseek-v4-flash' },
        },
      }),
    }, { surfaceOp: 'append' })
    result.session.append('tool/call', {
      turn: 1, step: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}',
    })
    const toolResult = result.session.append('tool/result', {
      turn: 1, step: 1,
      message: createToolResultMessage({
        callId: 'old-call' as never,
        content: [{ type: 'text', text: 'old output' }],
        isError: false,
      }),
    }, { surfaceOp: 'append' })
    const start = result.session.surface.nodes[0] as number
    result.session.append('user/message', createUserMessage({
      content: [{ type: 'text', text: 'summary replacement' }],
      source: { kind: 'plugin', plugin: 'compact' },
    }), {
      surfaceOp: { op: 'replace', start, end: toolResult.seq },
      sourceEventSeqs: [start, assistant.seq, toolResult.seq],
    })
    await tick()

    result.terminal.resize(89)
    await tick()
    const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
    expect(lastFullRender).toContain('summary replacement')
    expect(lastFullRender).not.toContain('old output')
    await dispose(result)
  })
})

describe('TUI user-interaction dialogs', () => {
  it('answers single-select, multi-select, custom, and optionless questions', async () => {
    const result = await setup({ config: { maxQuestionOptions: 1 } })

    const single = result.ctx.userInteraction.ask({
      questions: [{
        id: 'mode', header: 'Mode', question: 'Choose a mode', detail: 'This choice controls the next turn.',
        options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }],
      }],
    })
    await tick()
    expect(result.terminal.output).toContain('Choose a mode')
    expect(result.terminal.output).toContain('This choice controls the next turn.')
    expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · Mode')
    expect(result.terminal.output).toContain('1/2')
    result.terminal.send('\x1b[B')
    result.terminal.send('\r')
    await expect(single).resolves.toEqual({ answers: [{ id: 'mode', selected: ['Fast'] }] })

    const multi = result.ctx.userInteraction.ask({
      questions: [{ id: 'targets', question: 'Pick targets', multiSelect: true, options: [{ label: 'Code' }, { label: 'Docs' }] }],
    })
    await tick()
    result.terminal.send(' ')
    result.terminal.send('\x1b[B')
    result.terminal.send(' ')
    result.terminal.send('\r')
    await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] })

    const custom = result.ctx.userInteraction.ask({
      questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
    })
    await tick()
    const singleOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('Choose or type'))
    expect(singleOptionRender).not.toContain('↑/↓ navigate')
    result.terminal.send('\t')
    result.terminal.send('my choice')
    result.terminal.send('\r')
    await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] })

    const free = result.ctx.userInteraction.ask({ questions: [{ id: 'note', question: 'Add a note' }] })
    await tick()
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Enter an answer before submitting')
    result.terminal.send('ship it')
    result.terminal.send('\r')
    await expect(free).resolves.toEqual({ answers: [{ id: 'note', selected: [], custom: 'ship it' }] })
    await dispose(result)
  })

  it('handles option wrapping, deselection errors, and returning from custom input', async () => {
    const result = await setup({ config: { theme: { color: true } } })
    const single = result.ctx.userInteraction.ask({
      questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }],
    })
    const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' })
    await tick()
    expect(result.terminal.output).toContain('Two')
    result.terminal.send('\x03')
    await singleRejected

    const answer = result.ctx.userInteraction.ask({
      questions: [{
        id: 'options',
        question: 'Exercise options',
        multiSelect: true,
        options: [{ label: 'One', description: 'first' }, { label: 'Two' }],
      }],
    })
    const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
    await tick()
    result.terminal.send('\x1b[A')
    result.terminal.send('\x1b[B')
    result.terminal.send('\x1b[B')
    result.terminal.send('\x1b[A')
    result.terminal.send(' ')
    await tick()
    result.terminal.send('x')
    result.terminal.send(' ')
    result.terminal.send('\r')
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('Select at least one option')
    })
    result.terminal.send('c')
    await tick()
    result.terminal.send('\x1b')
    await tick()
    expect(result.terminal.output).toContain('Space toggle')
    result.terminal.send('\x03')
    await rejected
    await dispose(result)
  })

  it('asks batches in order and rejects cancelled or aborted work', async () => {
    const result = await setup()
    const preAborted = new AbortController()
    preAborted.abort()
    await expect(result.ctx.userInteraction.ask({
      questions: [{ id: 'pre-aborted', question: 'Already cancelled?' }],
      signal: preAborted.signal,
    })).rejects.toMatchObject({ code: 'ASK_ABORTED' })

    const batch = result.ctx.userInteraction.ask({
      questions: [
        { id: 'first', question: 'First?', options: [{ label: 'Yes' }] },
        { id: 'second', question: 'Second?' },
      ],
    })
    await tick()
    expect(result.terminal.output).toContain('Question 1/2 (2 unanswered)')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Second?')
    expect(result.terminal.output).toContain('Question 2/2 (1 unanswered)')
    result.terminal.send('done')
    result.terminal.send('\r')
    await expect(batch).resolves.toEqual({ answers: [
      { id: 'first', selected: ['Yes'] },
      { id: 'second', selected: [], custom: 'done' },
    ] })

    const cancelled = result.ctx.userInteraction.ask({ questions: [{ id: 'cancel', question: 'Cancel?' }] })
    const cancelledExpectation = expect(cancelled).rejects.toMatchObject({ code: 'ASK_ABORTED' })
    await tick()
    result.terminal.send('\x1b')
    await cancelledExpectation

    const controller = new AbortController()
    const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }], signal: controller.signal })
    const queuedController = new AbortController()
    const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }], signal: queuedController.signal })
    const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
    const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
    await tick()
    queuedController.abort()
    controller.abort()
    await activeExpectation
    await queuedExpectation
    await dispose(result)
  })

  it('rejects active and queued dialogs on disposal', async () => {
    const result = await setup()
    const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
    const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
    const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
    const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
    await tick()
    await result.controller.dispose()
    await activeExpectation
    await queuedExpectation
    await expect(result.ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
      .rejects.toMatchObject({ code: 'NO_PROVIDER' })
    await result.ctx.fiber.dispose()
  })

  it('rejects malformed questions when a dialog cannot be constructed', async () => {
    const result = await setup()
    const broken = {
      id: 'broken',
      question: 'Broken question',
      get options(): never {
        throw new Error('question setup failed')
      },
    }
    const answer = result.ctx.userInteraction.ask({ questions: [broken] })
    await expect(answer).rejects.toThrow('ask_user_question TUI failed: question setup failed')
    await tick()
    expect(result.terminal.output).toContain('TUI overlay failed: question setup failed')
    await dispose(result)
  })
})

describe('TUI extension service', () => {
  it('renders effect-owned plugin overlays in the shared FIFO and restores editor input', async () => {
    const result = await setup()
    const sessions: TuiOverlaySession[] = []
    const hosts: TuiOverlayHost[] = []
    const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
      expect(pluginCtx.tui.agent).toBe(result.agent)
      for (const label of ['first', 'second']) {
        sessions.push(pluginCtx.tui.openOverlay({
          create(host) {
            hosts.push(host)
            return {
              focused: false,
              render: width => [
                host.theme.accent(`${label} plugin overlay`),
                [
                  host.theme.text('text'),
                  host.theme.muted('muted'),
                  host.theme.dim('dim'),
                  host.theme.success('success'),
                  host.theme.warning('warning'),
                  host.theme.error('error'),
                  host.theme.bold('bold'),
                ].join(' '),
                `${String(host.viewport.columns)}x${String(host.viewport.rows)} · ${String(width)}`,
              ],
              handleInput(data) {
                host.invalidate()
                if (data === label[0]) host.close()
              },
              invalidate() {},
            }
          },
          options: { width: 50, maxHeight: 8, anchor: 'center', margin: 1 },
        }))
      }
    })
    await plugin
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('first plugin overlay')
    })
    expect(sessions.map(session => session.state)).toEqual(['active', 'queued'])
    expect(hosts).toHaveLength(1)

    const question = result.ctx.userInteraction.ask({
      questions: [{ id: 'after-plugin', question: 'Question after plugins?', options: [{ label: 'Yes' }] }],
    })
    result.terminal.send('f')
    await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'closed' })
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('second plugin overlay')
    })
    expect(hosts).toHaveLength(2)
    expect(sessions[1]?.state).toBe('active')

    result.terminal.send('s')
    await expect(sessions[1]!.closed).resolves.toEqual({ reason: 'closed' })
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('Question after plugins?')
    })
    result.terminal.send('\r')
    await expect(question).resolves.toEqual({
      answers: [{ id: 'after-plugin', selected: ['Yes'] }],
    })

    result.terminal.send('editor works again')
    result.terminal.send('\r')
    expect(result.agent.sent.at(-1)).toEqual([{ type: 'text', text: 'editor works again' }])
    await plugin.dispose()
    await dispose(result)
  })

  it('unloads and reloads dependent plugins with the mounted TUI', async () => {
    const result = await setup()
    const sessions: TuiOverlaySession[] = []
    const signals: AbortSignal[] = []
    let starts = 0
    const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
      starts += 1
      sessions.push(pluginCtx.tui.openOverlay({
        create(host) {
          signals.push(host.signal)
          return {
            render: () => [`plugin mount ${String(starts)}`],
            invalidate() {},
          }
        },
      }))
    })
    await plugin
    await vi.waitFor(() => {
      expect(result.terminal.output).toContain('plugin mount 1')
    })

    await result.controller.dispose()
    await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'owner-disposed' })
    expect(signals[0]?.aborted).toBe(true)
    expect(result.ctx.get('tui')).toBeUndefined()

    const secondTerminal = new FakeTerminal()
    const secondController = createTuiChat(result.ctx, {
      sessionId: result.agent.id,
      theme: { color: false },
      welcome: 'Mounted again.',
    }, {
      terminal: secondTerminal,
      exit: vi.fn(),
    })
    await vi.waitFor(() => {
      expect(starts).toBe(2)
      expect(secondTerminal.output).toContain('plugin mount 2')
    })
    await sessions[1]?.close()
    await secondController.dispose()
    await plugin.dispose()
    await result.ctx.fiber.dispose()
  })
})

describe('terminal mounting', () => {
  it('starts immediately when the configured agent already exists', async () => {
    const ctx = new Context()
    provideTokenMeter(ctx)
    await ctx.plugin(SessionStore)
    await ctx.plugin(AgentRegistry)
    await ctx.plugin(CommandService)
    await ctx.plugin(UserInteractionService)
    await ctx.plugin(TuiPromptService)
    ctx.provide('tools', { get: () => undefined } as never)
    const session = ctx.sessions.create(SessionId('main'))
    ctx.agents.register({
      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
      followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
    })
    const terminal = new FakeTerminal()
    mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
    await tick()
    expect(terminal.started).toBe(1)
    await ctx.fiber.dispose()
  })

  it('degrades /reload to a warning when mounted as a real plugin without a Loader', async () => {
    // Production shape: the TUI runs inside a plugin fiber, where a bare
    // `ctx.loader` proxy read would THROW `cannot get property without
    // inject` — only the non-throwing `ctx.get` lookup degrades gracefully.
    const ctx = new Context()
    provideTokenMeter(ctx)
    provideLlmCatalog(ctx)
    await ctx.plugin(SessionStore)
    await ctx.plugin(AgentRegistry)
    await ctx.plugin(CommandService)
    await ctx.plugin(UserInteractionService)
    await ctx.plugin(TuiPromptService)
    ctx.provide('tools', { get: () => undefined } as never)
    const session = ctx.sessions.create(SessionId('main'))
    ctx.agents.register({
      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
      followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
    })
    const terminal = new FakeTerminal()
    // Mirror dsh-tui's own inject (minus loader, the absence under test).
    await ctx.plugin({
      inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter', 'tuiPrompt'],
      apply: (pluginCtx: Context) => {
        mountTui(pluginCtx, { theme: { color: false } }, { terminal, exit: vi.fn() })
      },
    })
    await tick()
    expect(terminal.started).toBe(1)
    terminal.send('/reload')
    terminal.send('\r')
    await tick()
    expect(terminal.output).toContain('/reload needs the cordis Loader')
    await ctx.fiber.dispose()
  })

  it('waits for its configured agent before starting the TUI', async () => {
    const ctx = new Context()
    provideTokenMeter(ctx)
    await ctx.plugin(SessionStore)
    await ctx.plugin(AgentRegistry)
    await ctx.plugin(CommandService)
    await ctx.plugin(UserInteractionService)
    await ctx.plugin(TuiPromptService)
    ctx.provide('tools', { get: () => undefined } as never)
    const terminal = new FakeTerminal()
    mountTui(ctx, { sessionId: 'late-session', theme: { color: false } }, { terminal, exit: vi.fn() })
    expect(terminal.started).toBe(0)

    const otherSession = ctx.sessions.create(SessionId('other-session'))
    ctx.agents.register({
      id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
      followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
    })
    expect(terminal.started).toBe(0)

    const session = ctx.sessions.create(SessionId('late-session'))
    const agent = {
      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
      followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
    } as Agent
    ctx.agents.register(agent)
    await tick()
    expect(terminal.started).toBe(1)
    await ctx.fiber.dispose()
  })

  it('prints a matching live startup failure and exits instead of waiting forever', async () => {
    const ctx = new Context()
    provideTokenMeter(ctx)
    await ctx.plugin(SessionStore)
    await ctx.plugin(AgentRegistry)
    await ctx.plugin(CommandService)
    await ctx.plugin(UserInteractionService)
    await ctx.plugin(TuiPromptService)
    ctx.provide('tools', { get: () => undefined } as never)
    const terminal = new FakeTerminal()
    const exit = vi.fn()
    mountTui(ctx, { sessionId: 'main-session', theme: { color: false } }, { terminal, exit })

    ctx.emit('agent-loop/config-start-failed', SessionId('other-session'), new Error('other failed'))
    expect(terminal.output).toBe('')
    expect(exit).not.toHaveBeenCalled()
    ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007'))
    expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: resume \\x1b]2;failure-controlled\\x07\n')
    expect(exit).toHaveBeenCalledWith(1)

    const session = ctx.sessions.create(SessionId('main-session'))
    ctx.agents.register({
      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
      followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
    })
    await tick()
    expect(terminal.started).toBe(0)
    await ctx.fiber.dispose()
  })

  it('renders an uncoercible startup failure without escaping the display boundary', async () => {
    const ctx = new Context()
    provideTokenMeter(ctx)
    await ctx.plugin(SessionStore)
    await ctx.plugin(AgentRegistry)
    await ctx.plugin(CommandService)
    await ctx.plugin(UserInteractionService)
    await ctx.plugin(TuiPromptService)
    ctx.provide('tools', { get: () => undefined } as never)
    const terminal = new FakeTerminal()
    const exit = vi.fn()

    mountTui(ctx, { sessionId: 'main-session', theme: { color: false } }, { terminal, exit })
    ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), {
      toString(): string { throw new Error('coercion failed') },
    })

    expect(terminal.started).toBe(0)
    expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: <unrenderable value>\n')
    expect(exit).toHaveBeenCalledWith(1)
    await ctx.fiber.dispose()
  })

  it('rolls back providers, listeners, and terminal state when startup fails', async () => {
    const ctx = new Context()
    provideTokenMeter(ctx)
    await ctx.plugin(SessionStore)
    await ctx.plugin(AgentRegistry)
    await ctx.plugin(CommandService)
    await ctx.plugin(UserInteractionService)
    await ctx.plugin(TuiPromptService)
    ctx.provide('tools', { get: () => undefined } as never)
    const session = ctx.sessions.create(SessionId('failed-start-session'))
    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
    session.append('step/start', { turn: 1, step: 1 })
    ctx.agents.register({
      id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
      followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
    })
    const terminal = new FakeTerminal()
    terminal.start = () => { throw new Error('terminal startup failed') }

    expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', theme: { color: false } }, { terminal, exit: vi.fn() }))
      .toThrow('terminal startup failed')
    await tick()
    expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
    expect(terminal.stopped).toBe(1)
    expect(terminal.progress).toEqual([false, true, false])
    expect(ctx.get('tui')).toBeUndefined()
    await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
      .rejects.toMatchObject({ code: 'NO_PROVIDER' })
    session.append('assistant/chunk', {
      turn: 1,
      step: 1,
      chunk: { type: 'text-delta', index: 0, text: 'must not render' },
    })
    await tick()
    expect(terminal.output).not.toContain('must not render')
    await ctx.fiber.dispose()
  })

  it('throws when createTuiChat is called without the configured agent', async () => {
    const ctx = new Context()
    provideTokenMeter(ctx)
    await ctx.plugin(AgentRegistry)
    await ctx.plugin(CommandService)
    await ctx.plugin(UserInteractionService)
    await ctx.plugin(TuiPromptService)
    ctx.provide('tools', { get: () => undefined } as never)
    const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }
    expect(() => createTuiChat(ctx, { sessionId: 'missing' }, runtime)).toThrow('is not running')
    await ctx.fiber.dispose()
  })

  it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => {
    const result = await setup({ config: { theme: { color: true } } })
    // Initial render uses dark-optimised palette: SGR 2 (dim) for dim text.
    expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')

    // A report matching the current scheme is a no-op: no palette rebuild or
    // re-render (ESC [?997;1n = dark, the startup default).
    const beforeSameScheme = result.terminal.output.length
    result.terminal.send('\x1b[?997;1n')
    await tick()
    expect(result.terminal.output.length).toBe(beforeSameScheme)

    // Simulate the terminal responding with a light color scheme report
    // (ESC [?997;2n = light, ESC [?997;1n = dark).
    result.terminal.send('\x1b[?997;2n')
    await tick()
    await tick()

    // After switching to light-optimised palette: palette.dim uses ANSI 90
    // (gray) instead of SGR 2. The header now uses \x1b[90m for the detail
    // line. The cumulative output still contains the initial SGR 2 render,
    // so we assert that a LATER write (appended after the scheme switch)
    // uses ANSI 90 for the same header text.
    expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')

    // Switch back to dark scheme.
    result.terminal.send('\x1b[?997;1n')
    await tick()
    await tick()
    // After switching back, a new write uses SGR 2 for the header detail.
    expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
    await dispose(result)
  })

  it('keeps the dark palette when the terminal rejects the color-scheme query', async () => {
    class QueryFailTerminal extends FakeTerminal {
      override write(data: string): void {
        // The device-status query is the only write that fails; the promise
        // rejects and the swallowed `.catch` leaves the dark palette in place.
        if (data === '\x1b[?996n') throw new Error('query write failed')
        super.write(data)
      }
    }
    const terminal = new QueryFailTerminal()
    // Anchor cwd under $HOME so the prompt renders the `~/` abbreviation
    // deterministically; process.cwd() is not guaranteed under $HOME in CI.
    const result = await createTuiTestHarness(terminal, vi.fn(), {
      config: { theme: { color: true } },
      cwd: join(homedir(), 'projects', 'dsh-tui'),
    })
    await tick()
    expect(terminal.output).toContain('\x1b[94m~/')
    expect(terminal.output).toContain('\x1b[90m (tui-staging)')
    await disposeTuiTestHarness(result)
  })
  it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => {
    const refreshed: string[] = []
    let releaseRefresh!: () => void
    const gate = new Promise<void>((resolve) => { releaseRefresh = resolve })
    const result = await setup({
      configureContext: async (ctx) => {
        ctx.provide('tools', { get: () => undefined } as never)
        // A structural Loader: two file-backed subtrees and one plain entry.
        // The first subtree blocks on a gate so re-entry can be probed
        // deterministically mid-flight.
        ctx.provide('loader', {
          entries: () => [
            { subtree: { refresh: async () => { refreshed.push('root'); await gate } } },
            {},
            { subtree: { refresh: async () => { refreshed.push('nested') } } },
          ],
        } as never)
      },
    })
    result.terminal.send('/reload')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Reloading 2 config tree(s)')
    // Second /reload while the first is gated: refused, no extra refreshes.
    result.terminal.send('/reload')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('A config reload is already running.')
    expect(refreshed.sort()).toEqual(['nested', 'root'])
    releaseRefresh()
    await tick()
    expect(result.terminal.output).toContain('Config reload complete.')
    // The guard released: a third /reload runs again.
    result.terminal.send('/reload')
    result.terminal.send('\r')
    await tick()
    expect(refreshed).toHaveLength(4)
    await dispose(result)
  })

  it('reports a /reload failure if a refresh ever rejects', async () => {
    const result = await setup({
      configureContext: async (ctx) => {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.provide('loader', {
          entries: () => [{ subtree: { refresh: () => Promise.reject(new Error('disk gone')) } }],
        } as never)
      },
    })
    result.terminal.send('/reload')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('Config reload failed: disk gone')
    // The failure arm also releases the re-entrancy guard.
    result.terminal.send('/reload')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).not.toContain('A config reload is already running.')
    await dispose(result)
  })

  it('refuses /reload while the agent is running and allows it back at idle', async () => {
    const refreshed: string[] = []
    const result = await setup({
      status: 'running',
      configureContext: async (ctx) => {
        ctx.provide('tools', { get: () => undefined } as never)
        ctx.provide('loader', {
          entries: () => [{ subtree: { refresh: async () => { refreshed.push('tree') } } }],
        } as never)
      },
    })
    result.terminal.send('/reload')
    result.terminal.send('\r')
    await tick()
    expect(result.terminal.output).toContain('/reload requires an idle agent (status: running).')
    expect(refreshed).toHaveLength(0)
    // Back at idle the same command runs.
    result.agent.status = 'idle'
    result.terminal.send('/reload')
    result.terminal.send('\r')
    await tick()
    expect(refreshed).toHaveLength(1)
    expect(result.terminal.output).toContain('Config reload complete.')
    await dispose(result)
  })

})

describe('banner sweep reveal', () => {
  it('renders the product name through the brand-gradient path when truecolor is enabled', async () => {
    // The product name carries a per-letter 24-bit gradient from the brand
    // indigo to light blue; the per-letter layout is pinned by the
    // `banner-gradient` terminal snapshot.
    const result = await setup({ config: { theme: { color: true, truecolor: true } } })
    expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m')
    expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m')
    expect(result.terminal.output).toContain('HARNESS')
    await dispose(result)
  })

  it('sweeps the whole borderless banner in when no welcome is configured, ending complete', async () => {
    const intervals = vi.spyOn(globalThis, 'setInterval')
    const cleared = vi.spyOn(globalThis, 'clearInterval')
    const result = await setup({ omitWelcome: true })
    const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType<typeof setInterval>
    // Run the sweep to natural completion — it clears its own timer at the end.
    const done = (): boolean => cleared.mock.calls.some(call => call[0] === revealHandle)
    const deadline = Date.now() + 5000
    while (!done() && Date.now() < deadline) await tick()
    intervals.mockRestore()
    cleared.mockRestore()
    // The finished banner carries the title and the model • session detail.
    expect(result.terminal.output).toContain('DEEPSEEK')
    expect(result.terminal.output).toContain('HARNESS')
    expect(result.terminal.output).toContain('main-session')
    // Borderless: no box-drawing frame around the banner.
    expect(result.terminal.output).not.toContain('╭')
    expect(result.terminal.output).not.toContain('╮')
    // A mid-sweep frame rendered a clipped title: `DEEPSEEK` with no `HARNESS`
    // on the same line.
    const clipped = result.terminal.output
      .split('\n')
      .some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS'))
    expect(clipped).toBe(true)
    await dispose(result)
  })

  it('renders a configured welcome verbatim in a complete banner with no sweep', async () => {
    const result = await setup()
    await tick()
    expect(result.terminal.output).toContain('Coding agent ready.')
    expect(result.terminal.output).toContain('DEEPSEEK')
    expect(result.terminal.output).not.toContain('╭')
    // No reveal frames: the banner is drawn whole from the first render, so no
    // clipped-title frame ever appears.
    const clipped = result.terminal.output
      .split('\n')
      .some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS'))
    expect(clipped).toBe(false)
    await dispose(result)
  })

  it('omits the subtitle line entirely when no welcome is configured', async () => {
    const result = await setup({ omitWelcome: true })
    const deadline = Date.now() + 5000
    while (!result.terminal.output.includes('main-session') && Date.now() < deadline) await tick()
    // Banner is title + detail only — no subtitle between them.
    expect(result.terminal.output).toContain('deepseek-v4-flash')
    expect(result.terminal.output).not.toContain('ready.')
    await dispose(result)
  })

  it('stops a mid-sweep animation on dispose', async () => {
    // The output-stability probe alone is insensitive to a leaked interval
    // (pi-tui's stopped guard silences post-stop renders), so capture the
    // reveal's own interval handle and assert dispose clears exactly it.
    const intervals = vi.spyOn(globalThis, 'setInterval')
    const result = await setup({ omitWelcome: true })
    const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType<typeof setInterval>
    expect(revealHandle).toBeDefined()
    const cleared = vi.spyOn(globalThis, 'clearInterval')
    await dispose(result)
    expect(cleared.mock.calls.some(call => call[0] === revealHandle)).toBe(true)
    intervals.mockRestore()
    cleared.mockRestore()
    const settled = result.terminal.output.length
    await tick()
    await tick()
    expect(result.terminal.output.length).toBe(settled)
  })
})
