import {
  BoxRenderable,
  CliRenderEvents,
  RGBA,
  StyledText,
  SyntaxStyle,
  TextareaRenderable,
  TextRenderable,
  createCliRenderer,
  decodePasteBytes,
  getTreeSitterClient,
  stripAnsiSequences,
  type CliRenderer,
  type KeyEvent,
  type PasteEvent,
  type TextChunk,
  type ThemeMode,
  type TreeSitterClient,
} from "@opentui/core"

import {
  NanobotClient,
  fetchHistory,
  fetchGatewayConnection,
  fetchMentionCandidates,
  fetchSessionContext,
  fetchSessions,
  fetchSlashCommands,
  type ApiReauthenticator,
  type ConnectionStatus,
  type FileEditEvent,
  type GatewayApiConnection,
  type HistoryMessage,
  type InboundEvent,
  type MentionCandidate,
  type MessageOptions,
  type SlashCommand,
  type SessionSummary,
  type TokenUsage,
  type WorkspaceScopePayload,
} from "./protocol"
import {
  CommandMenu,
  resolveSlashCommandLifecycle,
  type CommandMenuTheme,
  type ResolvedSlashCommandLifecycle,
  type TuiCommand,
} from "./command-menu"
import { SessionMenu, sessionLabel } from "./session-menu"
import { ContextPanel, formatTokenCount, type ContextPanelTheme } from "./context-panel"
import {
  DiffViewer,
  latestTurnFileEdits,
  mergeFileEdits,
  type DiffViewerTheme,
} from "./diff-viewer"
import {
  Transcript,
  type TranscriptNavigation,
  type TranscriptTheme,
} from "./transcript"
import { ComposerDraft } from "./composer-draft"
import { BranchMenu, branchPoints } from "./branch-menu"
import {
  MentionMenu,
  insertMention,
  mentionOptions,
  mentionQuery,
  type MentionQuery,
} from "./mention-menu"
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
import { RuntimeControls } from "./runtime-controls"
import {
  contextualFooterHints,
  footerTelemetry,
  type FooterMode,
  type FooterHintTheme,
} from "./footer-hints"
import { createTuiHost, currentGitBranch, type TuiHost } from "./host"

interface AppOptions {
  wsUrl?: string
  bootstrapUrl?: string
  bootstrapSecret?: string
  apiUrl: string
  apiToken: string
  chatId?: string
  model: string
  modelPreset: string
  workspace: string
  hostWorkspace?: string
  branch?: string
  version: string
  access: string
  theme: "auto" | ThemeMode
}

interface ChatClient {
  readonly activeChatId: string
  connect(): void
  close(): void
  send(content: string, options?: MessageOptions): string
  attach(chatId: string): void
  newChat(scope?: WorkspaceScopePayload): void
  forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
  setWorkspaceScope(scope: WorkspaceScopePayload): void
}

interface Palette {
  referenceBackground: string
  text: string
  muted: string
  faint: string
  border: string
  accent: string
  link: string
  success: string
  error: string
  user: string
  userBackground: string
  warm: string
  cool: string
}

const DARK: Palette = {
  referenceBackground: "#0E0F11",
  text: "#ECEDEE",
  muted: "#A1A1AA",
  faint: "#71717A",
  border: "#3F3F46",
  accent: "#EF8E30",
  link: "#60A5FA",
  success: "#5CC489",
  error: "#F87171",
  user: "#EF8E30",
  // Codex-style turn anchor: 12% white over the reference dark background.
  userBackground: "#2B2C2E",
  warm: "#C26A25",
  cool: "#1795A2",
}

const LIGHT: Palette = {
  referenceBackground: "#FAFAFA",
  text: "#18181B",
  muted: "#6F6F78",
  faint: "#8A8A94",
  border: "#D4D4D8",
  accent: "#B94D0B",
  link: "#1D4ED8",
  success: "#166534",
  error: "#B91C1C",
  user: "#B94D0B",
  // Codex-style turn anchor: 4% black over the reference light background.
  userBackground: "#F0F0F0",
  warm: "#C2410C",
  cool: "#0F766E",
}

const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
const SHIMMER_PAUSE = 16
const SHIMMER_BAND = 4
const SHIMMER_INTERVAL_MS = 80
const LOCAL_COMMANDS: TuiCommand[] = [
  {
    command: "/sessions",
    title: "Sessions",
    description: "Find and switch conversations",
    action: "sessions",
  },
  {
    command: "/new-chat",
    title: "New saved chat",
    description: "Keep this conversation and start another",
    action: "new-chat",
  },
  {
    command: "/context",
    title: "Agent context",
    description: "Explain what this session contributes to the next prompt",
    action: "context",
  },
  {
    command: "/diff",
    title: "Last turn diff",
    description: "Inspect file changes from the latest turn",
    action: "diff",
  },
  {
    command: "/branch",
    title: "Branch from reply",
    description: "Continue from an earlier completed reply",
    action: "branch",
  },
  {
    command: "/exit",
    title: "Exit",
    description: "Close this terminal UI",
    action: "exit",
  },
]

function syntaxStyle(palette: Palette): SyntaxStyle {
  const color = (value: string) => {
    const parsed = RGBA.fromHex(value)
    return { fg: parsed }
  }
  return SyntaxStyle.fromStyles({
    default: color(palette.text),
    keyword: { ...color(palette.accent), bold: true },
    string: color(palette.success),
    comment: { ...color(palette.muted), italic: true },
    number: color(palette.link),
    function: color(palette.warm),
    type: color(palette.cool),
    variable: color(palette.text),
    property: color(palette.link),
    "markup.heading": { ...color(palette.accent), bold: true },
    "markup.strong": { ...color(palette.text), bold: true },
    "markup.italic": { ...color(palette.muted), italic: true },
    "markup.link": { ...color(palette.link), underline: true },
    "markup.link.label": { ...color(palette.link), underline: true },
    "markup.link.url": { ...color(palette.link), underline: true },
    "markup.raw": color(palette.warm),
    conceal: color(palette.faint),
  })
}

function transcriptTheme(palette: Palette, backgroundKnown: boolean): TranscriptTheme {
  return {
    text: palette.text,
    muted: palette.muted,
    error: palette.error,
    user: palette.user,
    userBackground: backgroundKnown ? palette.userBackground : null,
    border: palette.border,
    syntax: syntaxStyle(palette),
  }
}

function commandMenuTheme(palette: Palette): CommandMenuTheme {
  return {
    text: palette.text,
    muted: palette.muted,
    border: palette.border,
    selectedBackground: palette.userBackground,
  }
}

function runtimeControlsTheme(palette: Palette) {
  return {
    ...commandMenuTheme(palette),
    accent: palette.accent,
    faint: palette.faint,
  }
}

function contextPanelTheme(palette: Palette): ContextPanelTheme {
  return {
    text: palette.text,
    border: palette.border,
    accent: palette.accent,
  }
}

function diffViewerTheme(palette: Palette, backgroundKnown: boolean): DiffViewerTheme {
  const light = palette === LIGHT
  return {
    text: palette.text,
    muted: palette.muted,
    border: palette.border,
    accent: palette.accent,
    success: palette.success,
    error: palette.error,
    addedBackground: backgroundKnown ? light ? "#E7F6EC" : "#142D22" : null,
    removedBackground: backgroundKnown ? light ? "#FCE8EA" : "#352024" : null,
    syntax: syntaxStyle(palette),
  }
}

function queuePreviewTheme(palette: Palette): QueuePreviewTheme {
  return {
    accent: palette.accent,
    muted: palette.muted,
    faint: palette.faint,
  }
}

function footerHintTheme(palette: Palette): FooterHintTheme {
  return {
    accent: palette.accent,
    danger: palette.error,
    muted: palette.muted,
    separator: palette.faint,
  }
}

function shimmerStatus(
  label: string,
  suffix: string,
  frame: number,
  palette: Palette,
): StyledText {
  const chars = Array.from(label)
  const base = RGBA.fromHex(palette.muted).toInts()
  const highlight = RGBA.fromHex(palette.accent).toInts()
  // Sweep immediately, then leave a quiet pause before repeating. The text
  // keeps a constant width throughout, so the footer never jitters.
  const position = frame % (chars.length + SHIMMER_PAUSE)
  const chunks: TextChunk[] = chars.map((text, index) => {
    const distance = Math.abs(index - position)
    const intensity = distance > SHIMMER_BAND
      ? 0
      : (1 + Math.cos(Math.PI * distance / SHIMMER_BAND)) / 2
    return {
      __isChunk: true,
      text,
      fg: RGBA.fromInts(
        Math.round(base[0] + (highlight[0] - base[0]) * intensity),
        Math.round(base[1] + (highlight[1] - base[1]) * intensity),
        Math.round(base[2] + (highlight[2] - base[2]) * intensity),
      ),
    }
  })
  chunks.push({ __isChunk: true, text: suffix, fg: RGBA.fromHex(palette.muted) })
  return new StyledText(chunks)
}

function formatElapsed(milliseconds: number): string {
  const seconds = Math.max(0, Math.floor(milliseconds / 1000))
  if (seconds < 60) return `${seconds}s`
  return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`
}

function singleLine(value: string, limit = 120): string {
  return value.replace(/\s+/gu, " ").trim().slice(0, limit)
}

async function copyWithSystemClipboard(text: string): Promise<void> {
  const commands = process.platform === "darwin"
    ? [["pbcopy"]]
    : process.platform === "win32"
      ? [["clip.exe"]]
      : [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
  for (const command of commands) {
    try {
      const child = Bun.spawn(command, { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
      child.stdin.write(text)
      child.stdin.end()
      if (await child.exited === 0) return
    } catch {
      // Try the next platform clipboard provider.
    }
  }
  throw new Error("no clipboard provider available")
}

export class NanobotTui {
  private readonly renderer: CliRenderer
  private readonly transcript: Transcript
  private readonly commandMenu: CommandMenu
  private readonly sessionMenu: SessionMenu
  private readonly mentionMenu: MentionMenu
  private readonly branchMenu: BranchMenu
  private readonly runtimeControls: RuntimeControls
  private readonly contextPanel: ContextPanel
  private readonly diffViewer: DiffViewer
  private readonly queuePreview: QueuePreview
  private readonly client: ChatClient
  private readonly shell: BoxRenderable
  private readonly title: BoxRenderable
  private readonly titleText: TextRenderable
  private readonly composerFrame: BoxRenderable
  private readonly composer: TextareaRenderable
  private readonly status: TextRenderable
  private readonly meta: TextRenderable
  private readonly host: TuiHost
  private readonly draft = new ComposerDraft()
  private readonly promptQueue = new PromptQueue()
  private palette: Palette
  private activeThemeMode: ThemeMode
  private backgroundKnown: boolean
  private activeTurn = false
  private activeTurnId: string | null = null
  private activeLabel = "Thinking"
  private activeStartedAt = 0
  private lastProgress = ""
  private finalMessage = ""
  private turnHadAnswer = false
  private historyLoaded = false
  private historyBeforeCursor: string | null = null
  private historyHasMore = false
  private historyLoadingOlder = false
  private attachedOnce = false
  private pendingEvents: InboundEvent[] | null = null
  private hydrationId = 0
  private ready = false
  private shimmerFrame = 0
  private shimmerTimer: ReturnType<typeof setInterval> | null = null
  private submitPending = false
  private submitGeneration = 0
  private readonly promptHistory: string[] = []
  private historyCursor = 0
  private historyDraft = ""
  private defaultModelName: string
  private defaultModelPreset: string
  private modelName: string
  private modelPreset: string
  private sessionModelPreset: string | null | undefined
  private sessionTitle = ""
  private sessionMetadataId = 0
  private contextTokens: number | null = null
  private contextWindowTokens: number | null = null
  private lastUsage: TokenUsage | null = null
  private readyDetail = ""
  private mentionCandidates: MentionCandidate[] = []
  private activeMentionQuery: MentionQuery | null = null
  private transcriptNavigation: TranscriptNavigation = {
    awayFromBottom: false,
    unseenOutput: false,
  }
  private quitting = false
  private sessionLoadId = 0
  private sessionLoading = false
  private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
  private readonly modelCommandTurns = new Set<string>()
  private readonly silentCommandTurns = new Set<string>()
  private currentFileEdits: FileEditEvent[] = []
  private lastFileEdits: FileEditEvent[] = []
  private currentTask = ""
  private currentAction = ""
  private hostBlocked = false
  private hostWorkspace: string
  private hostBranch: string
  private readonly apiReauthenticator: ApiReauthenticator | undefined
  private apiRefreshPromise: Promise<GatewayApiConnection> | null = null

  private constructor(
    renderer: CliRenderer,
    private readonly options: AppOptions,
    client?: ChatClient,
    treeSitterClient = getTreeSitterClient(),
    host: TuiHost = createTuiHost({}),
  ) {
    this.renderer = renderer
    this.defaultModelName = options.model
    this.defaultModelPreset = options.modelPreset
    this.modelName = options.model
    this.modelPreset = options.modelPreset
    this.hostWorkspace = options.hostWorkspace || options.workspace
    this.hostBranch = options.branch || ""
    this.apiReauthenticator = options.bootstrapUrl
      ? (rejectedApiToken) => this.refreshApiConnection(rejectedApiToken)
      : undefined
    this.sessionModelPreset = options.chatId ? undefined : null
    this.backgroundKnown = options.theme !== "auto" || renderer.themeMode !== null
    this.activeThemeMode = this.resolveThemeMode(renderer.themeMode)
    this.palette = this.activeThemeMode === "light" ? LIGHT : DARK
    this.host = host
    this.transcript = new Transcript(
      renderer,
      transcriptTheme(this.palette, this.backgroundKnown),
      treeSitterClient,
      (state) => this.handleTranscriptNavigation(state),
      !host.hosted,
    )
    this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
    this.commandMenu.setCommands([], LOCAL_COMMANDS)
    this.sessionMenu = new SessionMenu(
      renderer,
      commandMenuTheme(this.palette),
      (session) => this.switchSession(session),
    )
    this.mentionMenu = new MentionMenu(renderer, commandMenuTheme(this.palette))
    this.branchMenu = new BranchMenu(renderer, commandMenuTheme(this.palette))
    this.contextPanel = new ContextPanel(renderer, contextPanelTheme(this.palette))
    this.diffViewer = new DiffViewer(
      renderer,
      diffViewerTheme(this.palette, this.backgroundKnown),
      treeSitterClient,
    )
    this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
    this.client = client || new NanobotClient({
      ...(options.bootstrapUrl
        ? {
            resolveConnection: () => fetchGatewayConnection(
              options.bootstrapUrl || "",
              options.bootstrapSecret || "",
              options.apiUrl,
              `tui-${process.pid}`,
            ),
            onConnection: (connection) => this.useGatewayConnection(
              connection.apiUrl,
              connection.apiToken,
            ),
            connectionRetryLabel: "Starting local gateway",
            reconnectDelayMs: 100,
            startupRetryMaxDelayMs: 250,
          }
        : { url: options.wsUrl }),
      chatId: options.chatId,
      initialWorkspaceScope: {
        project_path: options.workspace,
        access_mode: options.access.toLocaleLowerCase().includes("full") ? "full" : "restricted",
      },
      onEvent: (event) => this.accept(event),
      onStatus: (status, detail) => this.handleStatus(status, detail),
    })

    // The terminal owns its canvas. Keeping the default-background intent is
    // essential in embedded terminals, where painting our own near-black RGB
    // only colors occupied cells and turns long output into dark strips.
    this.renderer.setBackgroundColor(RGBA.defaultBackground())
    this.shell = new BoxRenderable(renderer, {
      id: "nanobot-tui-footer",
      width: "100%",
      height: "100%",
      paddingLeft: 1,
      paddingRight: 1,
      flexDirection: "column",
      backgroundColor: RGBA.defaultBackground(),
      onMouseDown: (event) => {
        if (event.button !== 0) return
        if (!this.diffViewer.visible) {
          // OpenTUI applies automatic focus after mouse handlers run. Prevent
          // a focusable transcript ancestor from stealing text focus back
          // after the composer has been restored here.
          event.preventDefault()
          this.composer.focus()
        }
        // Runtime pickers are transient popovers. A primary click anywhere
        // outside their trigger or body dismisses them; hide() restores the
        // composer focus through the shared visibility callback.
        this.dismissRuntimeControls()
        if (this.sessionLoading || this.sessionMenu.visible) {
          this.closeSessions()
        }
        // Selection belongs to transcript/input content, never to empty chrome.
        // Clearing it here prevents default-background cells from lingering as
        // opaque blocks in terminals with differential repainting.
        if (event.target && !event.target.selectable) {
          this.renderer.clearSelection()
        }
      },
    })
    this.title = new BoxRenderable(renderer, {
      id: "nanobot-tui-title",
      width: "100%",
      height: 1,
      flexShrink: 0,
      flexDirection: "row",
      alignItems: "center",
      backgroundColor: RGBA.defaultBackground(),
    })
    this.titleText = new TextRenderable(renderer, {
      id: "nanobot-tui-title-text",
      content: "nanobot",
      height: 1,
      flexShrink: 0,
      truncate: true,
      fg: this.palette.muted,
      selectable: false,
      ...(host.hosted ? {} : {
        onMouseOver: () => { this.titleText.fg = this.palette.accent },
        onMouseOut: () => this.renderTitleColor(),
        onMouseDown: (event) => {
          if (event.button !== 0) return
          event.preventDefault()
          event.stopPropagation()
          this.renderer.clearSelection()
          if (this.sessionLoading || this.sessionMenu.visible) {
            this.closeSessions()
            return
          }
          void this.openSessions()
        },
      }),
    })
    this.runtimeControls = new RuntimeControls(
      renderer,
      runtimeControlsTheme(this.palette),
      {
        apiUrl: options.apiUrl,
        apiToken: options.apiToken,
        model: this.modelName,
        modelPreset: this.modelPreset,
        workspace: options.workspace,
        access: options.access,
        reauthenticateApi: this.apiReauthenticator,
        // Runtime settings are session state. Changing them during a turn is
        // safe and takes effect when the next provider call starts.
        available: () => this.ready,
        beforeOpen: () => this.closeTransientMenus(),
        refreshScope: () => this.refreshSessionMetadata(this.client.activeChatId),
        onModel: (preset) => this.sendGatewayCommand(`/model ${preset}`, "side_channel", true),
        onAccess: (scope) => {
          try {
            this.client.setWorkspaceScope(scope)
            this.status.content = "Changing access…"
          } catch (error) {
            this.status.content = error instanceof Error ? error.message : String(error)
          }
        },
        onStatus: (message) => { this.status.content = message },
        onVisibilityChange: (visible) => {
          this.updateMeta()
          if (!visible) this.composer.focus()
        },
      },
    )
    this.title.add(this.titleText)
    if (!host.hosted) {
      this.title.add(this.runtimeControls.modelText)
      this.title.add(this.runtimeControls.accessText)
      this.title.add(this.runtimeControls.contextText)
    }
    const composerSurface = this.composerSurface()
    this.composerFrame = new BoxRenderable(renderer, {
      id: "nanobot-tui-composer-frame",
      width: "100%",
      minHeight: 1,
      flexShrink: 0,
      border: ["left"],
      borderColor: this.palette.accent,
      paddingLeft: 1,
      paddingRight: 1,
      backgroundColor: composerSurface,
    })
    this.composer = new TextareaRenderable(renderer, {
      id: "nanobot-tui-composer",
      width: "100%",
      minHeight: 1,
      maxHeight: 8,
      wrapMode: "word",
      placeholder: COMPOSER_PLACEHOLDER,
      placeholderColor: this.palette.muted,
      textColor: this.palette.text,
      focusedTextColor: this.palette.text,
      backgroundColor: composerSurface,
      focusedBackgroundColor: composerSurface,
      cursorColor: this.palette.accent,
      // A steady line cursor avoids the block-cell trails produced by some
      // terminals when a retained full-screen UI redraws around the composer.
      cursorStyle: { style: "line", blinking: false },
      showCursor: true,
      keyBindings: [
        { name: "return", shift: true, action: "newline" },
        { name: "return", meta: true, action: "newline" },
        { name: "return", ctrl: true, action: "newline" },
        { name: "j", ctrl: true, action: "newline" },
        { name: "linefeed", action: "newline" },
        { name: "return", action: "submit" },
      ],
      onContentChange: () => {
        this.draft.prune(this.composer.plainText)
        this.runtimeControls.hide()
        if (this.contextPanel.visible && this.composer.plainText) this.contextPanel.hide()
        this.syncComposerPlaceholder()
        if (this.sessionMenu.visible) this.syncSessionMenu()
        else if (this.branchMenu.visible) this.syncBranchMenu()
        else this.syncComposerMenus()
        this.resizeComposer()
      },
      // IMEs may commit their final composed glyph after Enter. Matching the
      // OpenCode/OpenTUI integration, defer twice before reading plainText.
      onSubmit: () => this.deferSubmit(),
      onPaste: (event) => this.handlePaste(event),
    })
    this.status = new TextRenderable(renderer, {
      id: "nanobot-tui-status",
      content: "Connecting…",
      fg: this.palette.muted,
      height: 1,
      width: "auto",
      minWidth: 0,
      flexGrow: 1,
      flexShrink: 1,
      selectable: false,
    })
    this.meta = new TextRenderable(renderer, {
      id: "nanobot-tui-meta",
      content: "",
      fg: this.palette.faint,
      height: 1,
      width: "auto",
      flexShrink: 1,
      selectable: false,
    })

    const statusRow = new BoxRenderable(renderer, {
      id: "nanobot-tui-status-row",
      width: "100%",
      height: 1,
      flexShrink: 0,
      flexDirection: "row",
      justifyContent: "space-between",
      gap: 2,
    })
    this.composerFrame.add(this.composer)
    statusRow.add(this.status)
    statusRow.add(this.meta)
    this.shell.add(this.transcript.root)
    this.shell.add(this.commandMenu.root)
    this.shell.add(this.sessionMenu.root)
    this.shell.add(this.mentionMenu.root)
    this.shell.add(this.branchMenu.root)
    this.shell.add(this.contextPanel.root)
    this.shell.add(this.runtimeControls.menuRoot)
    this.shell.add(this.title)
    this.shell.add(this.queuePreview.root)
    this.shell.add(this.composerFrame)
    this.shell.add(statusRow)
    this.shell.add(this.diffViewer.root)
    this.renderer.root.add(this.shell)

    this.renderer.keyInput.on("keypress", this.handleKey)
    this.renderer.on(CliRenderEvents.THEME_MODE, this.handleTheme)
    this.renderer.on(CliRenderEvents.CAPABILITIES, this.handleCapabilities)
    this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
    this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
    this.renderer.console.onCopySelection = (text) => void this.copySelection(text)
    this.handleResize()
    this.composer.focus()
    this.transcript.header(options)
    this.syncHostMetadata()
  }

  static async create(options: AppOptions): Promise<NanobotTui> {
    const host = createTuiHost()
    const renderer = await createCliRenderer({
      targetFps: 30,
      exitOnCtrlC: false,
      useMouse: true,
      screenMode: host.hosted ? "main-screen" : "alternate-screen",
      externalOutputMode: "passthrough",
      consoleMode: "disabled",
    })
    return NanobotTui.mount(renderer, options, undefined, undefined, host)
  }

  static mount(
    renderer: CliRenderer,
    options: AppOptions,
    client?: ChatClient,
    treeSitterClient?: TreeSitterClient,
    host?: TuiHost,
  ): NanobotTui {
    return new NanobotTui(renderer, options, client, treeSitterClient, host)
  }

  async start(): Promise<void> {
    // Network setup and small menu payloads do not depend on terminal colors.
    // Start them while OSC theme detection is in flight instead of serializing
    // up to one second of otherwise independent startup work.
    this.host.reportState("unknown", "Connecting")
    this.client.connect()
    void this.loadCommands()
    void this.loadMentions()
    this.runtimeControls.preload()
    this.renderer.start()
    // OpenTUI learns the real terminal background through OSC 10/11. Wait for
    // that bounded probe after first paint. The neutral terminal background is
    // safe to render immediately, and the detected palette can be applied later.
    if (this.options.theme === "auto") await this.renderer.waitForThemeMode(1_000)
    if (this.quitting) return
    if (this.options.theme === "auto" && this.renderer.themeMode) {
      this.applyTheme(this.renderer.themeMode)
    }
  }

  stop(): void {
    this.quit()
  }

  private deferSubmit(): void {
    if (this.submitPending) return
    this.submitPending = true
    const generation = ++this.submitGeneration
    setTimeout(() => setTimeout(() => {
      if (generation !== this.submitGeneration) return
      this.submitPending = false
      if (this.composer.isDestroyed) return
      this.submit()
    }, 0), 0)
  }

  private submit(): void {
    if (this.quitting || this.composer.isDestroyed) return
    const visibleContent = this.composer.plainText.trim()
    const content = this.draft.expand(visibleContent).trim()
    if (this.sessionLoading) {
      this.status.content = "Loading sessions…"
      return
    }
    if (this.runtimeControls.visible) {
      this.runtimeControls.choose()
      return
    }
    if (this.sessionMenu.visible) {
      const session = this.sessionMenu.choose()
      if (session) this.switchSession(session)
      return
    }
    if (this.branchMenu.visible) {
      const point = this.branchMenu.choose()
      if (point) this.createBranch(point.beforeUserIndex, point.preview)
      return
    }
    if (this.mentionMenu.visible && this.activeMentionQuery) {
      const candidate = this.mentionMenu.choose()
      if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
      return
    }
    if (!visibleContent) return
    if (["exit", "quit", "/quit", ":q"].includes(visibleContent.toLowerCase())) {
      this.quit()
      return
    }
    const completion = this.commandMenu.completion(visibleContent)
    if (completion) {
      this.setComposer(completion)
      this.commandMenu.hide()
      this.updateMeta()
      return
    }
    const command = this.commandMenu.resolve(visibleContent)
    if (command?.source === "tui") {
      if (command.command.action === "sessions") void this.openSessions()
      else if (command.command.action === "context") void this.openContext()
      else if (command.command.action === "diff") this.openDiff()
      else if (command.command.action === "branch") void this.openBranch()
      else if (command.command.action === "exit") this.quit()
      else this.startNewChat()
      return
    }
    if (command?.source === "gateway") {
      const lifecycle = resolveSlashCommandLifecycle(visibleContent, command.command)
      if (lifecycle) this.sendGatewayCommand(visibleContent, lifecycle)
      return
    }
    if (visibleContent.startsWith("!")) {
      this.sendGatewayCommand(visibleContent, "side_channel", false, { userShell: true })
      return
    }
    if (!this.ready) {
      this.status.content = "Preparing chat…"
      return
    }
    const prompt = { content, options: mentionOptions(content, this.availableMentions()) }
    if (this.activeTurn) {
      this.sendPrompt(prompt, true)
      return
    }
    this.sendPrompt(prompt)
  }

  private sendPrompt(prompt: QueuedPrompt, steering = false): boolean {
    let turnId: string
    try {
      turnId = this.client.send(prompt.content, prompt.options)
    } catch (error) {
      this.status.content = error instanceof Error ? error.message : String(error)
      return false
    }
    this.clearComposer()
    this.commandMenu.hide()
    this.mentionMenu.hide()
    this.recordPrompt(prompt.content)
    this.transcript.user(prompt.content, turnId)
    this.hostBlocked = false
    this.setCurrentTask(prompt.content)
    if (steering) {
      this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}`
      this.updateMeta()
      return true
    }
    this.beginTurn(turnId)
    return true
  }

  private beginTurn(turnId: string | null, startedAt?: number): void {
    this.activeTurnId = turnId
    this.readyDetail = ""
    this.finalMessage = ""
    this.turnHadAnswer = false
    this.lastProgress = ""
    this.activeLabel = "Thinking"
    this.currentFileEdits = []
    this.setCurrentAction("Thinking")
    this.setActive(true, startedAt)
    this.reportHostWorking()
  }

  private reconcileTurnOwnership(event: {
    turn_id?: string
    active_turn_id?: string
    starts_turn?: boolean
    started_at?: number
  }): void {
    if (event.active_turn_id && this.activeTurn) {
      this.activeTurnId = event.active_turn_id
    } else if (event.active_turn_id || (event.starts_turn && !this.activeTurn)) {
      this.beginTurn(
        event.active_turn_id || event.turn_id || null,
        typeof event.started_at === "number" ? event.started_at * 1000 : undefined,
      )
    }
  }

  accept(event: InboundEvent): void {
    if (event.event === "attached") {
      this.host.reportSession(event.chat_id)
      if (event.usage) this.lastUsage = event.usage
      if (event.model_preset !== undefined) {
        this.applyModelPreset(event.model_preset)
        this.updateTitle()
      }
      this.commandTurns.clear()
      this.modelCommandTurns.clear()
      const restoring = this.attachedOnce
      this.attachedOnce = true
      if (restoring) {
        this.activeTurnId = null
        this.setActive(false)
      }
      const queuesEvents = restoring || (!this.historyLoaded && Boolean(this.options.chatId))
      if (queuesEvents) {
        this.ready = false
        this.pendingEvents = []
      }
      const hydrationId = ++this.hydrationId
      void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
        if (hydrationId === this.hydrationId) this.flushPendingEvents()
      })
      return
    }
    if (
      "chat_id" in event
      && event.chat_id
      && this.client.activeChatId
      && event.chat_id !== this.client.activeChatId
    ) return
    if (this.pendingEvents) {
      this.pendingEvents.push(event)
      return
    }

    switch (event.event) {
      case "message_accepted":
        this.reconcileTurnOwnership(event)
        return
      case "user_message": {
        const attachments = event.media_urls?.map((media) => media.name).filter(Boolean) || []
        const content = [
          event.text,
          attachments.length ? `Attachments: ${attachments.join(", ")}` : "",
        ].filter(Boolean).join("\n")
        if (this.transcript.user(content, event.turn_id)) this.recordPrompt(event.text)
        this.hostBlocked = false
        this.setCurrentTask(event.text)
        this.reconcileTurnOwnership(event)
        if (this.activeTurn) this.reportHostWorking()
        return
      }
      case "delta":
        this.setActive(true)
        this.activeLabel = "Writing"
        if (!this.currentAction) this.setCurrentAction("Writing")
        this.reportHostWorking()
        this.turnHadAnswer = true
        this.transcript.stream(event.text)
        return
      case "message":
        if (event.turn_id && this.commandTurns.has(event.turn_id) && !event.kind) {
          const lifecycle = this.commandTurns.get(event.turn_id)
          if (lifecycle !== "agent_turn") {
            this.commandTurns.delete(event.turn_id)
            const silent = this.silentCommandTurns.delete(event.turn_id)
            if (this.modelCommandTurns.delete(event.turn_id)) {
              void this.refreshSessionMetadata(event.chat_id)
            }
            if (!silent) this.transcript.assistant(event.text)
            if (!this.activeTurn) this.status.content = "Ready"
            return
          }
        }
        if (event.kind) {
          this.activeLabel = event.kind === "tool_hint" ? "Working" : "Thinking"
          this.lastProgress = this.transcript.progress(event.text, event.tool_events)
          if (this.lastProgress) this.setCurrentAction(this.lastProgress)
          else if (!this.currentAction) this.setCurrentAction(this.activeLabel)
          this.setActive(true)
          this.reportHostWorking()
        } else {
          this.finalMessage = event.text
        }
        return
      case "file_edit":
        this.activeLabel = "Editing"
        this.currentFileEdits = mergeFileEdits(this.currentFileEdits, event.edits)
        if (this.diffViewer.visible) this.diffViewer.update(this.currentFileEdits)
        this.lastProgress = this.transcript.fileEdits(event.edits)
        this.setCurrentAction(this.lastProgress || "Editing")
        this.setActive(true)
        this.reportHostWorking()
        return
      case "reasoning_delta":
        this.activeLabel = "Thinking"
        this.setActive(true)
        return
      case "reasoning_end":
        return
      case "stream_end":
        if (event.text && !this.turnHadAnswer) this.turnHadAnswer = true
        if (event.resuming && event.merge_next) {
          this.transcript.reconcileStream(event.text || "")
        } else {
          this.transcript.finishStream(event.text || "")
        }
        return
      case "turn_end":
        if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) return
        if (event.turn_id) {
          this.commandTurns.delete(event.turn_id)
          this.modelCommandTurns.delete(event.turn_id)
        }
        this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
        this.transcript.finishActivity()
        if (this.currentFileEdits.length) this.lastFileEdits = this.currentFileEdits
        this.currentFileEdits = []
        if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
        this.finalMessage = ""
        this.turnHadAnswer = false
        this.activeTurnId = null
        if (event.usage) this.lastUsage = event.usage
        if (typeof event.context_window_tokens === "number") {
          this.contextWindowTokens = event.context_window_tokens
        }
        this.applyHostGoalState(event.goal_state)
        this.updateTitle()
        this.setActive(false)
        // A synthetic/rehydrated turn may already be idle, in which case
        // setActive(false) intentionally does not repaint the footer.
        this.updateMeta()
        this.readyDetail = typeof event.latency_ms === "number"
          ? `${(event.latency_ms / 1000).toFixed(1)}s`
          : ""
        this.status.content = this.readyStatus()
        this.reportHostResting()
        if (this.contextTokens !== null) void this.refreshContextEstimate(event.chat_id)
        this.sendNextFollowUp()
        return
      case "goal_status":
        if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) return
        if (event.status === "running") {
          if (event.turn_id) this.activeTurnId = event.turn_id
          this.activeLabel = "Working"
          if (!this.currentAction) this.setCurrentAction("Working")
          this.setActive(true, typeof event.started_at === "number" ? event.started_at * 1000 : undefined)
          this.reportHostWorking()
        } else {
          this.setActive(false)
          this.reportHostResting()
        }
        return
      case "goal_state":
        this.applyHostGoalState(event.goal_state)
        if (!this.activeTurn) this.reportHostResting()
        return
      case "turn_model_updated":
        if (typeof event.context_window_tokens === "number") {
          this.contextWindowTokens = event.context_window_tokens
        }
        this.setTurnModel(event.model_name, event.model_preset)
        return
      case "runtime_model_updated":
        this.setDefaultModel(event.model_name, event.model_preset)
        return
      case "session_updated":
        if (event.workspace_scope) this.applyWorkspaceScope(event.workspace_scope)
        if (
          !this.sessionTitle
          || this.sessionTitle === "New chat"
          || this.sessionTitle === "Untitled chat"
          || event.scope === "metadata"
        ) {
          void this.refreshSessionMetadata(event.chat_id)
        }
        return
      case "error":
        const commandLifecycle = event.turn_id ? this.commandTurns.get(event.turn_id) : undefined
        if (event.turn_id) {
          this.commandTurns.delete(event.turn_id)
          this.modelCommandTurns.delete(event.turn_id)
          this.silentCommandTurns.delete(event.turn_id)
        }
        if (event.turn_id && this.activeTurnId && event.turn_id !== this.activeTurnId) {
          this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
          return
        }
        this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
        this.transcript.notice(event.reason || event.detail || "Unknown gateway error", true)
        if (!commandLifecycle || commandLifecycle === "agent_turn") {
          if (this.currentFileEdits.length) this.lastFileEdits = this.currentFileEdits
          this.currentFileEdits = []
          if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
        }
        this.finalMessage = ""
        this.turnHadAnswer = false
        this.restoreQueuedPrompts()
        this.setActive(false)
        this.setCurrentAction(event.reason || event.detail || "Error")
        this.reportHostResting()
        return
    }
  }

  private async prepareChat(chatId: string, restoring: boolean, hydrationId: number): Promise<void> {
    try {
      if (restoring) {
        this.contextPanel.hide()
        this.diffViewer.hide()
        this.currentFileEdits = []
        this.lastFileEdits = []
        this.historyBeforeCursor = null
        this.historyHasMore = false
        this.historyLoadingOlder = false
        this.transcript.reset({
          model: this.modelName || this.modelPreset,
          workspace: this.options.workspace,
          version: this.options.version,
          access: this.options.access,
        })
      }
      if (restoring || (!this.historyLoaded && this.options.chatId)) {
        this.historyLoaded = true
        const history = await fetchHistory(
          this.options.apiUrl,
          this.options.apiToken,
          chatId,
          undefined,
          this.apiReauthenticator,
        )
        if (hydrationId !== this.hydrationId) return
        this.historyBeforeCursor = history.beforeCursor
        this.historyHasMore = history.hasMoreBefore
        this.transcript.history(history.messages)
        this.restorePromptHistory(history.messages)
        const reversedHistory = [...history.messages].reverse()
        const lastUser = reversedHistory.find((message) => message.role === "user")
        if (lastUser) this.setCurrentTask(lastUser.content)
        const lastActivity = reversedHistory.find((message) => message.role === "activity")
        if (lastActivity) {
          this.setCurrentAction(lastActivity.fileEdits?.length ? "Edited" : lastActivity.content)
        }
        this.lastFileEdits = latestTurnFileEdits(history.messages)
        if (this.diffViewer.visible) this.diffViewer.update(this.lastFileEdits)
      }
    } catch (error) {
      if (hydrationId !== this.hydrationId) return
      this.transcript.notice(error instanceof Error ? error.message : String(error), true)
    } finally {
      if (hydrationId !== this.hydrationId) return
      this.ready = true
      if (!this.activeTurn) {
        this.status.content = this.readyStatus()
        this.reportHostResting()
      }
    }
  }

  private flushPendingEvents(): void {
    const events = this.pendingEvents
    this.pendingEvents = null
    for (const event of events || []) this.accept(event)
  }

  private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
    this.options.apiUrl = apiUrl
    this.options.apiToken = apiToken
    this.runtimeControls.useApiConnection(apiUrl, apiToken)
  }

  private useGatewayConnection(apiUrl: string, apiToken: string): void {
    this.updateGatewayApiConnection(apiUrl, apiToken)
    void this.loadCommands()
    void this.loadMentions()
  }

  private async refreshApiConnection(
    rejectedApiToken: string,
  ): Promise<GatewayApiConnection> {
    if (this.options.apiToken && rejectedApiToken !== this.options.apiToken) {
      return { apiUrl: this.options.apiUrl, apiToken: this.options.apiToken }
    }
    if (this.apiRefreshPromise) return this.apiRefreshPromise
    const refresh = fetchGatewayConnection(
      this.options.bootstrapUrl || "",
      this.options.bootstrapSecret || "",
      this.options.apiUrl,
      `tui-${process.pid}`,
    ).then((connection) => {
      this.updateGatewayApiConnection(connection.apiUrl, connection.apiToken)
      return connection
    })
    this.apiRefreshPromise = refresh
    try {
      return await refresh
    } finally {
      if (this.apiRefreshPromise === refresh) this.apiRefreshPromise = null
    }
  }

  private handleStatus(status: ConnectionStatus, detail?: string): void {
    if (status === "connected") {
      this.ready = false
      this.host.reportState("unknown", "Connecting")
      this.status.content = "Connected · preparing chat…"
      return
    }
    if (status === "connecting") {
      this.ready = false
      const label = detail === "Starting local gateway"
        ? detail
        : detail ? "Reconnecting" : "Connecting"
      this.host.reportState("unknown", label)
      if (detail) this.setActive(false)
      this.status.content = `${label}…`
      return
    }
    if (status === "error") {
      this.setActive(false)
      this.host.reportState("unknown", detail || "Connection error")
      this.status.content = detail || "Connection error"
      return
    }
    if (!this.quitting) {
      this.setActive(false)
      this.host.reportState("unknown", "Disconnected")
      this.status.content = "Disconnected"
    }
  }

  private setActive(active: boolean, startedAt?: number): void {
    if (this.activeTurn === active) {
      if (active && startedAt !== undefined) this.activeStartedAt = startedAt
      return
    }
    this.activeTurn = active
    this.updateMeta()
    if (active) {
      this.activeStartedAt = startedAt ?? Date.now()
      this.shimmerFrame = 0
      this.renderActiveStatus()
      this.shimmerTimer = setInterval(() => {
        this.shimmerFrame += 1
        this.renderActiveStatus()
      }, SHIMMER_INTERVAL_MS)
      return
    }
    if (this.shimmerTimer) clearInterval(this.shimmerTimer)
    this.shimmerTimer = null
    this.lastProgress = ""
    this.status.content = this.readyStatus()
  }

  private renderActiveStatus(): void {
    const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
    const progress = this.lastProgress
      ? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}`
      : ""
    const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
    const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
    this.status.content = shimmerStatus(
      this.activeLabel,
      `  ${elapsed}${progress}${queued}${navigation}`,
      this.shimmerFrame,
      this.palette,
    )
  }

  private readyStatus(detail = this.readyDetail): string {
    if (this.transcriptNavigation.awayFromBottom) {
      return this.transcriptNavigation.unseenOutput
        ? "New output · Ctrl+End latest"
        : "History · Ctrl+End latest"
    }
    if (detail) return `Ready · ${detail}`
    return this.historyHasMore ? "Ready · PageUp for earlier history" : "Ready"
  }

  private sendNextFollowUp(): void {
    if (!this.ready || this.activeTurn || this.quitting) return
    const prompt = this.promptQueue.takeFollowUp()
    if (!prompt) return
    this.syncQueuePreview()
    this.sendPrompt(prompt)
  }

  private restoreQueuedPrompts(): void {
    const queued = this.promptQueue.restore()
    if (!queued.length) return
    this.syncQueuePreview()
    const current = this.draft.expand(this.composer.plainText).trim()
    this.setComposer([current, ...queued.map((prompt) => prompt.content)].filter(Boolean).join("\n\n"))
  }

  private queueFollowUp(): void {
    if (!this.activeTurn || !this.ready) return
    const visibleContent = this.composer.plainText.trim()
    const content = this.draft.expand(visibleContent).trim()
    if (!content) return
    this.promptQueue.enqueue({
      content,
      options: mentionOptions(content, this.availableMentions()),
    })
    this.clearComposer()
    this.commandMenu.hide()
    this.mentionMenu.hide()
    this.recordPrompt(content)
    this.syncQueuePreview()
    this.renderActiveStatus()
    this.updateMeta()
  }

  private editLastFollowUp(): boolean {
    const prompt = this.promptQueue.takeLast()
    if (!prompt) return false
    const current = this.draft.expand(this.composer.plainText).trim()
    this.setComposer([prompt.content, current].filter(Boolean).join("\n\n"))
    this.syncQueuePreview()
    this.renderActiveStatus()
    this.updateMeta()
    return true
  }

  private clearPromptQueue(): void {
    this.promptQueue.clear()
    this.syncQueuePreview()
  }

  private syncQueuePreview(): void {
    this.queuePreview.update(this.promptQueue.snapshot().map(({ content }) => content))
  }

  private handleTranscriptNavigation(state: TranscriptNavigation): void {
    this.transcriptNavigation = state
    if (this.activeTurn) this.renderActiveStatus()
    else if (this.ready) this.status.content = this.readyStatus()
    this.updateMeta()
  }

  private handleKey = (key: KeyEvent): void => {
    if (this.diffViewer.visible) {
      if (key.ctrl && key.name === "c") {
        const selected = this.renderer.getSelection()?.getSelectedText()
        if (selected) void this.copySelection(selected)
      } else if (this.diffViewer.handleKey(key) && !this.diffViewer.visible) {
        this.composer.focus()
        this.status.content = this.readyStatus()
        this.updateMeta()
      }
      key.preventDefault()
      return
    }
    if (this.contextPanel.visible && key.name === "escape") {
      this.contextPanel.hide()
      this.status.content = this.readyStatus()
      this.updateMeta()
      key.preventDefault()
      return
    }
    if (this.sessionLoading && key.name === "escape") {
      this.closeSessions()
      this.status.content = this.readyStatus()
      key.preventDefault()
      return
    }
    if (this.runtimeControls.handleKey(key)) return
    if (this.sessionMenu.visible) {
      if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
        this.sessionMenu.move(key.name === "up" ? -1 : 1)
        key.preventDefault()
        return
      }
      if (key.name === "escape") {
        this.closeSessions()
        key.preventDefault()
        return
      }
    }
    if (this.branchMenu.visible) {
      if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
        this.branchMenu.move(key.name === "up" ? -1 : 1)
        key.preventDefault()
        return
      }
      if (key.name === "escape") {
        this.closeBranch()
        key.preventDefault()
        return
      }
    }
    if (this.mentionMenu.visible) {
      if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
        this.mentionMenu.move(key.name === "up" ? -1 : 1)
        key.preventDefault()
        return
      }
      if (!key.ctrl && !key.meta && key.name === "tab" && this.activeMentionQuery) {
        const candidate = this.mentionMenu.choose()
        if (candidate) this.chooseMention(candidate, this.activeMentionQuery)
        key.preventDefault()
        return
      }
      if (key.name === "escape") {
        this.mentionMenu.hide()
        this.activeMentionQuery = null
        this.updateMeta()
        key.preventDefault()
        return
      }
    }
    if (this.commandMenu.visible) {
      if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
        this.commandMenu.move(key.name === "up" ? -1 : 1)
        key.preventDefault()
        return
      }
      if (!key.ctrl && !key.meta && key.name === "tab") {
        const completion = this.commandMenu.complete()
        if (completion) {
          this.setComposer(completion)
          this.commandMenu.hide()
        }
        this.updateMeta()
        key.preventDefault()
        return
      }
      if (key.name === "escape") {
        this.commandMenu.hide()
        this.updateMeta()
        key.preventDefault()
        return
      }
    }
    if (this.activeTurn && !key.ctrl && !key.meta && key.name === "tab") {
      this.queueFollowUp()
      key.preventDefault()
      return
    }
    if (this.activeTurn && key.meta && key.name === "up") {
      if (this.editLastFollowUp()) key.preventDefault()
      return
    }
    if (key.ctrl && key.name === "o") {
      const expanded = this.transcript.toggleActivityDetails()
      if (expanded === null) return
      this.status.content = expanded ? "Tool details expanded" : "Tool details collapsed"
      key.preventDefault()
      return
    }
    if (!key.ctrl && !key.meta && (key.name === "up" || key.name === "down")) {
      const direction = key.name === "up" ? -1 : 1
      const boundary = direction < 0 ? 0 : this.composer.plainText.length
      if (this.composer.cursorOffset !== boundary) {
        const visualRow = this.composer.scrollY + this.composer.visualCursor.visualRow
        const edgeRow = direction < 0 ? 0 : Math.max(0, this.composer.virtualLineCount - 1)
        if (visualRow === edgeRow) this.composer.cursorOffset = boundary
        return
      }
      if (this.navigateHistory(direction)) {
        key.preventDefault()
        return
      }
    }
    if (key.ctrl && key.name === "c") {
      key.preventDefault()
      const selected = this.renderer.getSelection()?.getSelectedText()
      if (selected) {
        void this.copySelection(selected)
        return
      }
      if (this.activeTurn) {
        this.restoreQueuedPrompts()
        try {
          this.client.send("/stop")
          this.status.content = "Stopping…"
        } catch {
          this.setActive(false)
        }
      } else if (this.composer.plainText) {
        this.clearComposer()
        this.status.content = this.readyStatus()
      } else {
        this.quit()
      }
      return
    }
    if (key.ctrl && key.name === "d" && !this.composer.plainText) {
      key.preventDefault()
      this.quit()
      return
    }
    if (key.name === "pageup" || key.name === "pagedown") {
      key.preventDefault()
      const pageUp = key.name === "pageup"
      const wasAtTop = this.transcript.atTop
      this.transcript.scrollByPage(pageUp ? -1 : 1)
      if (pageUp && (wasAtTop || this.transcript.atTop)) void this.loadOlderHistory()
      return
    }
    if (key.ctrl && (key.name === "home" || key.name === "end")) {
      key.preventDefault()
      this.transcript.scrollToEdge(key.name === "home" ? "top" : "bottom")
    }
  }

  private navigateHistory(direction: -1 | 1): boolean {
    if (this.promptHistory.length === 0) return false
    if (direction < 0) {
      if (this.historyCursor === this.promptHistory.length) this.historyDraft = this.composer.plainText
      if (this.historyCursor === 0) return false
      this.historyCursor -= 1
    } else {
      if (this.historyCursor >= this.promptHistory.length) return false
      this.historyCursor += 1
    }
    const content = this.historyCursor === this.promptHistory.length
      ? this.historyDraft
      : this.promptHistory[this.historyCursor] || ""
    this.composer.setText(content)
    this.composer.cursorOffset = direction < 0 ? 0 : content.length
    return true
  }

  private handleTheme = (mode: ThemeMode): void => {
    if (this.options.theme !== "auto") return
    this.applyTheme(mode)
  }

  private handleCapabilities = (): void => {
    this.updateMeta()
  }

  private resolveThemeMode(detected: ThemeMode | null): ThemeMode {
    return this.options.theme === "auto" ? detected ?? "dark" : this.options.theme
  }

  private applyTheme(mode: ThemeMode): void {
    const backgroundWasUnknown = !this.backgroundKnown
    this.backgroundKnown = true
    if (this.activeThemeMode === mode && !backgroundWasUnknown) return
    this.activeThemeMode = mode
    this.palette = mode === "light" ? LIGHT : DARK
    this.transcript.setTheme(transcriptTheme(this.palette, this.backgroundKnown))
    this.commandMenu.setTheme(commandMenuTheme(this.palette))
    this.sessionMenu.setTheme(commandMenuTheme(this.palette))
    this.mentionMenu.setTheme(commandMenuTheme(this.palette))
    this.branchMenu.setTheme(commandMenuTheme(this.palette))
    this.runtimeControls.setTheme(runtimeControlsTheme(this.palette))
    this.contextPanel.setTheme(contextPanelTheme(this.palette))
    this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
    this.queuePreview.setTheme(queuePreviewTheme(this.palette))
    this.updateComposerAppearance()
    this.composer.textColor = this.palette.text
    this.composer.focusedTextColor = this.palette.text
    this.composer.cursorColor = this.palette.accent
    this.renderTitleColor()
    this.status.fg = this.palette.muted
    this.meta.fg = this.palette.faint
    this.updateMeta()
  }

  private handleResize = (): void => {
    this.resizeComposer()
    this.contextPanel.resize(this.renderer.height)
    this.diffViewer.resize(this.renderer.width)
    if (!this.host.hosted) this.title.visible = this.renderer.height >= 14
    this.runtimeControls.resize(this.renderer.width)
    this.updateTitle()
    this.updateMeta()
  }

  private updateMeta(): void {
    const mode: FooterMode = this.runtimeControls.visible ? "runtime"
      : this.mentionMenu.visible ? "mention"
      : this.activeTurn ? "active"
      : this.branchMenu.visible ? "branch"
      : this.commandMenu.visible ? "command"
      : this.sessionMenu.visible ? "session"
      : this.contextPanel.visible ? "context"
      : this.transcriptNavigation.awayFromBottom ? "history"
      : "ready"
    if (mode === "ready") {
      this.meta.content = footerTelemetry(
        this.lastUsage,
        this.renderer.width,
        footerHintTheme(this.palette),
      )
      return
    }
    this.meta.content = contextualFooterHints(
      mode,
      this.renderer.width,
      footerHintTheme(this.palette),
      process.platform,
      Boolean(this.renderer.capabilities?.kitty_keyboard),
    )
  }

  private setTurnModel(model: string, preset?: string | null): void {
    this.modelName = model
    this.modelPreset = preset?.trim() || "default"
    this.updateTitle()
  }

  private setDefaultModel(model: string, preset?: string | null): void {
    this.defaultModelName = model
    this.defaultModelPreset = preset?.trim() || "default"
    if (this.sessionModelPreset === null) {
      this.modelName = this.defaultModelName
      this.modelPreset = this.defaultModelPreset
      this.updateTitle()
    }
  }

  private applySessionModel(session: SessionSummary): void {
    this.applyModelPreset(session.modelPreset)
  }

  private applySessionScope(session: SessionSummary): void {
    if (session.workspaceScope) this.applyWorkspaceScope(session.workspaceScope)
  }

  private applyModelPreset(preset: string | null): void {
    const currentModel = this.modelName
    const currentPreset = this.modelPreset
    this.sessionModelPreset = preset
    this.modelPreset = preset || this.defaultModelPreset
    this.modelName = this.modelPreset === this.defaultModelPreset
      ? this.defaultModelName
      : this.modelPreset === currentPreset ? currentModel : ""
  }

  private updateTitle(): void {
    if (this.host.hosted) {
      this.titleText.maxWidth = Math.max(8, this.renderer.width - 4)
      this.titleText.content = this.currentTask ? `› ${this.currentTask}` : ""
      // In a hosted pane this is the resume anchor, not decorative chrome.
      // Keep it visible even when Herdr temporarily makes the pane very short.
      this.title.visible = Boolean(this.currentTask)
      this.syncHostMetadata()
      return
    }
    const identity = this.sessionTitle.trim() || "nanobot"
    this.titleText.maxWidth = Math.max(8, Math.floor(this.renderer.width * 0.38))
    this.titleText.content = identity
    const context = this.contextTokens === null
      ? ""
      : `  ·  ~${formatTokenCount(this.contextTokens)}${this.contextWindowTokens
        ? `/${formatTokenCount(this.contextWindowTokens)}`
        : ""} ctx`
    this.runtimeControls.updateModel(this.modelName, this.modelPreset)
    this.runtimeControls.updateContext(context)
    this.syncHostMetadata()
  }

  private renderTitleColor(): void {
    this.titleText.fg = !this.host.hosted && (this.sessionLoading || this.sessionMenu.visible)
      ? this.palette.accent
      : this.palette.muted
  }

  private setCurrentTask(task: string): void {
    const next = singleLine(task)
    if (!next || next === this.currentTask) return
    this.currentTask = next
    this.updateTitle()
  }

  private setCurrentAction(action: string): void {
    const next = singleLine(action.replace(/^\s*[·›✓×]\s*/u, ""), 80)
    if (!next || next === this.currentAction) return
    this.currentAction = next
    this.syncHostMetadata()
  }

  private clearHostContext(): void {
    this.currentTask = ""
    this.currentAction = ""
    this.hostBlocked = false
    this.updateTitle()
  }

  private syncHostMetadata(): void {
    const model = [this.modelPreset, this.modelName].filter(Boolean).join(" · ")
    this.host.reportMetadata({
      model,
      branch: this.hostBranch,
      workspace: this.hostWorkspace,
      task: this.currentTask,
      action: this.currentAction,
    })
  }

  private applyHostGoalState(state: Record<string, unknown> | undefined): void {
    if (!state) return
    this.hostBlocked = state.status === "blocked"
    if (!this.hostBlocked) return
    const summary = typeof state.ui_summary === "string" ? state.ui_summary : ""
    const recap = typeof state.recap === "string" ? state.recap : ""
    const objective = typeof state.objective === "string" ? state.objective : ""
    this.setCurrentAction(summary || recap || objective || "Needs input")
    this.host.reportState("blocked", summary || recap || objective || this.currentTask)
  }

  private reportHostResting(): void {
    this.host.reportState(
      this.hostBlocked ? "blocked" : "idle",
      this.hostBlocked ? this.currentAction || this.currentTask : this.currentAction,
    )
  }

  private reportHostWorking(): void {
    if (!this.hostBlocked) this.host.reportState("working", this.currentTask)
  }

  private resizeComposer(): void {
    const verticalPadding = this.renderer.height >= 12 ? 1 : 0
    const maxContentHeight = Math.max(1, Math.min(12, Math.floor(this.renderer.height / 3)))
    this.composer.minHeight = 1
    this.composer.maxHeight = maxContentHeight
    this.composerFrame.paddingTop = verticalPadding
    this.composerFrame.paddingBottom = verticalPadding
    this.composerFrame.minHeight = 1 + verticalPadding * 2
    this.composerFrame.maxHeight = maxContentHeight + verticalPadding * 2
  }

  private composerSurface(): RGBA {
    return this.backgroundKnown
      ? RGBA.fromHex(this.palette.userBackground)
      : RGBA.defaultBackground()
  }

  private updateComposerAppearance(): void {
    const surface = this.composerSurface()
    this.composerFrame.backgroundColor = surface
    this.composerFrame.borderColor = this.palette.accent
    this.composer.backgroundColor = surface
    this.composer.focusedBackgroundColor = surface
    this.composer.placeholderColor = this.palette.muted
  }

  private syncComposerPlaceholder(): void {
    // OpenTUI normally suppresses placeholder glyphs while the editor is not
    // empty. Explicitly removing them also invalidates their old cells, which
    // prevents stale placeholder text in differential/embedded terminals.
    const placeholder = this.composer.plainText
      ? null
      : this.sessionMenu.visible
        ? "Search sessions"
        : this.branchMenu.visible ? "Search branch points" : COMPOSER_PLACEHOLDER
    if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder
  }

  private syncCommandMenu(): void {
    const limit = this.renderer.height >= 20 ? 6 : 3
    this.commandMenu.update(this.composer.plainText, limit)
    this.updateMeta()
  }

  private syncComposerMenus(): void {
    this.activeMentionQuery = mentionQuery(this.composer.plainText, this.composer.cursorOffset)
    const candidates = this.availableMentions()
    if (this.activeMentionQuery && candidates.length) {
      this.commandMenu.hide()
      const limit = this.renderer.height >= 20 ? 7 : 4
      if (this.mentionMenu.visible) this.mentionMenu.update(this.activeMentionQuery.query, limit)
      else this.mentionMenu.show(candidates, this.activeMentionQuery.query, limit)
      this.updateMeta()
      return
    }
    this.mentionMenu.hide()
    this.syncCommandMenu()
  }

  private syncSessionMenu(): void {
    const limit = this.renderer.height >= 20 ? 8 : 4
    this.sessionMenu.update(this.composer.plainText, limit)
    this.updateMeta()
  }

  private syncBranchMenu(): void {
    const limit = this.renderer.height >= 20 ? 8 : 4
    this.branchMenu.update(this.composer.plainText, limit)
    this.updateMeta()
  }

  private chooseMention(candidate: MentionCandidate, query: MentionQuery): void {
    const inserted = insertMention(this.composer.plainText, candidate, query)
    this.composer.setText(inserted.value)
    this.composer.cursorOffset = inserted.cursor
    this.mentionMenu.hide()
    this.activeMentionQuery = null
    this.syncComposerPlaceholder()
    this.updateMeta()
  }

  private setComposer(content: string): void {
    this.draft.clear()
    this.composer.setText(content)
    this.composer.cursorOffset = content.length
  }

  private clearComposer(): void {
    this.draft.clear()
    this.composer.setText("")
  }

  private handlePaste(event: PasteEvent): void {
    event.preventDefault()
    const value = stripAnsiSequences(decodePasteBytes(event.bytes))
    const insertion = this.draft.paste(value)
    if (!insertion.text) return
    this.composer.insertText(insertion.text)
    if (insertion.compacted) {
      this.status.content = `Pasted ${insertion.description} · review before sending`
    }
  }

  private async loadCommands(): Promise<void> {
    let discovered: SlashCommand[] = []
    try {
      discovered = await fetchSlashCommands(
        this.options.apiUrl,
        this.options.apiToken,
        this.apiReauthenticator,
      )
    } catch {
      // Local navigation remains available against older gateways.
    }
    const commands = new Map(discovered.map((command) => [command.command, command]))
    this.commandMenu.setCommands([...commands.values()], LOCAL_COMMANDS)
    this.syncCommandMenu()
  }

  private closeTransientMenus(): void {
    this.commandMenu.hide()
    this.sessionMenu.hide()
    this.mentionMenu.hide()
    this.branchMenu.hide()
    this.contextPanel.hide()
    this.activeMentionQuery = null
  }

  private dismissRuntimeControls(): void {
    if (this.runtimeControls.visible) this.runtimeControls.hide()
  }

  private applyWorkspaceScope(scope: WorkspaceScopePayload): void {
    if (scope.project_path) {
      this.hostWorkspace = scope.project_path
      this.hostBranch = currentGitBranch(scope.project_path)
      this.syncHostMetadata()
    }
    this.runtimeControls.updateWorkspaceScope(scope)
    this.updateTitle()
    if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
  }

  private async loadMentions(): Promise<void> {
    try {
      this.mentionCandidates = await fetchMentionCandidates(
        this.options.apiUrl,
        this.options.apiToken,
        this.apiReauthenticator,
      )
      if (this.activeMentionQuery) this.syncComposerMenus()
    } catch {
      // Mentions are additive; plain text input remains fully functional.
    }
  }

  private availableMentions(): MentionCandidate[] {
    const currentKey = this.client.activeChatId
      ? `websocket:${this.client.activeChatId}`
      : ""
    return this.mentionCandidates.filter((candidate) => (
      candidate.session?.session_key !== currentKey
    ))
  }

  private async openBranch(): Promise<void> {
    if (this.activeTurn) {
      this.status.content = "Wait for the current turn or press Ctrl+C"
      return
    }
    if (!this.ready) {
      this.status.content = "Preparing chat…"
      return
    }
    this.commandMenu.hide()
    this.sessionMenu.hide()
    this.contextPanel.hide()
    this.clearComposer()
    this.status.content = "Loading branch points…"
    const chatId = this.client.activeChatId
    try {
      const history = await fetchHistory(
        this.options.apiUrl,
        this.options.apiToken,
        chatId,
        undefined,
        this.apiReauthenticator,
      )
      if (chatId !== this.client.activeChatId) return
      const points = branchPoints(history.messages)
      const limit = this.renderer.height >= 20 ? 8 : 4
      this.branchMenu.open(points, limit)
      this.syncComposerPlaceholder()
      this.updateMeta()
      this.status.content = points.length ? `${points.length} branch points` : "No completed replies"
    } catch (error) {
      this.status.content = error instanceof Error ? error.message : String(error)
    }
  }

  private createBranch(beforeUserIndex: number, preview: string): void {
    if (!this.ready || this.activeTurn) return
    this.branchMenu.hide()
    this.clearComposer()
    try {
      if (!this.client.forkChat) throw new Error("branching is unavailable")
      this.ready = false
      this.clearPromptQueue()
      this.sessionMetadataId += 1
      this.sessionTitle = `Fork · ${preview.slice(0, 48)}`
      this.clearHostContext()
      this.setCurrentTask(preview)
      this.contextTokens = null
      this.lastUsage = null
      this.readyDetail = ""
      this.updateTitle()
      this.status.content = "Creating branch…"
      this.client.forkChat(
        this.client.activeChatId,
        beforeUserIndex,
        this.sessionTitle,
      )
    } catch (error) {
      this.ready = true
      this.status.content = error instanceof Error ? error.message : String(error)
    }
  }

  private closeBranch(): void {
    this.branchMenu.hide()
    this.clearComposer()
    this.syncComposerPlaceholder()
    if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
    this.updateMeta()
  }

  private async openSessions(): Promise<void> {
    if (this.activeTurn) {
      this.status.content = "Wait for the current turn or press Ctrl+C"
      return
    }
    this.commandMenu.hide()
    this.dismissRuntimeControls()
    this.mentionMenu.hide()
    this.branchMenu.hide()
    this.contextPanel.hide()
    this.clearComposer()
    this.sessionLoading = true
    this.renderTitleColor()
    const loadId = ++this.sessionLoadId
    this.status.content = "Loading sessions…"
    try {
      const sessions = await fetchSessions(
        this.options.apiUrl,
        this.options.apiToken,
        this.apiReauthenticator,
      )
      if (this.quitting || loadId !== this.sessionLoadId) return
      this.sessionLoading = false
      const current = sessions.find((session) => session.chatId === this.client.activeChatId)
      if (current) {
        this.sessionTitle = sessionLabel(current)
        this.applySessionModel(current)
        this.applySessionScope(current)
        this.updateTitle()
      }
      const limit = this.renderer.height >= 20 ? 8 : 4
      this.sessionMenu.open(sessions, this.client.activeChatId, limit)
      this.renderTitleColor()
      this.sessionMenu.update(this.composer.plainText, limit)
      this.syncComposerPlaceholder()
      this.updateMeta()
      this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions"
    } catch (error) {
      if (loadId !== this.sessionLoadId) return
      this.sessionLoading = false
      this.renderTitleColor()
      this.status.content = error instanceof Error ? error.message : String(error)
    }
  }

  private switchSession(session: SessionSummary): void {
    if (this.activeTurn) {
      this.status.content = "Wait for the current turn or press Ctrl+C"
      return
    }
    if (session.chatId === this.client.activeChatId) {
      this.sessionTitle = sessionLabel(session)
      this.applySessionModel(session)
      this.applySessionScope(session)
      this.updateTitle()
      this.closeSessions()
      this.status.content = this.readyStatus()
      return
    }
    if (!this.ready) {
      this.status.content = "Preparing chat…"
      return
    }
    this.closeSessions()
    try {
      this.ready = false
      this.clearPromptQueue()
      this.sessionMetadataId += 1
      this.clearHostContext()
      this.sessionTitle = sessionLabel(session)
      this.applySessionModel(session)
      this.applySessionScope(session)
      this.contextTokens = null
      this.lastUsage = null
      this.readyDetail = ""
      this.updateTitle()
      this.status.content = "Opening session…"
      this.client.attach(session.chatId)
    } catch (error) {
      this.status.content = error instanceof Error ? error.message : String(error)
    }
  }

  private startNewChat(): void {
    if (this.activeTurn) {
      this.status.content = "Wait for the current turn or press Ctrl+C"
      return
    }
    if (!this.ready) {
      this.status.content = "Preparing chat…"
      return
    }
    this.commandMenu.hide()
    this.sessionMenu.hide()
    this.mentionMenu.hide()
    this.branchMenu.hide()
    this.contextPanel.hide()
    this.clearComposer()
    try {
      this.ready = false
      this.clearPromptQueue()
      this.sessionMetadataId += 1
      this.clearHostContext()
      this.sessionTitle = "New chat"
      this.sessionModelPreset = null
      this.modelName = this.defaultModelName
      this.modelPreset = this.defaultModelPreset
      this.contextTokens = null
      this.lastUsage = null
      this.readyDetail = ""
      this.updateTitle()
      this.status.content = "Starting a new chat…"
      this.client.newChat(this.runtimeControls.workspaceScope)
    } catch (error) {
      this.status.content = error instanceof Error ? error.message : String(error)
    }
  }

  private sendGatewayCommand(
    content: string,
    lifecycle: ResolvedSlashCommandLifecycle,
    silent = false,
    options: MessageOptions = {},
  ): void {
    if (!this.ready) {
      this.status.content = "Preparing chat…"
      return
    }
    if (this.activeTurn && lifecycle === "agent_turn") {
      this.status.content = "A turn is already running · Ctrl+C to stop"
      return
    }
    let turnId: string
    try {
      turnId = this.client.send(content, options)
    } catch (error) {
      this.status.content = error instanceof Error ? error.message : String(error)
      return
    }
    this.commandTurns.set(turnId, lifecycle)
    if (silent) this.silentCommandTurns.add(turnId)
    if (/^\/model(?:\s|$)/iu.test(content)) this.modelCommandTurns.add(turnId)
    this.clearComposer()
    this.commandMenu.hide()
    if (!silent && lifecycle !== "stop_active_turn") this.transcript.user(content)
    if (!silent) this.recordPrompt(content)

    if (lifecycle === "agent_turn") {
      this.hostBlocked = false
      this.setCurrentTask(content)
      this.activeTurnId = turnId
      this.finalMessage = ""
      this.turnHadAnswer = false
      this.lastProgress = ""
      this.activeLabel = "Thinking"
      this.currentFileEdits = []
      this.setCurrentAction("Thinking")
      this.setActive(true)
      this.reportHostWorking()
    } else if (lifecycle === "finalize_active_turn") {
      this.activeTurnId = null
      this.transcript.finishStream(this.turnHadAnswer ? "" : this.finalMessage)
      this.transcript.finishActivity()
      this.finalMessage = ""
      this.turnHadAnswer = false
      this.setActive(false)
      this.reportHostResting()
      this.status.content = "Resetting chat…"
    } else if (lifecycle === "stop_active_turn") {
      this.activeTurnId = null
      this.setActive(false)
      this.reportHostResting()
      this.status.content = "Stopping…"
    } else if (!this.activeTurn) {
      this.status.content = `Running ${content.split(/\s+/u, 1)[0]}…`
    }
  }

  private recordPrompt(content: string): void {
    if (this.promptHistory.at(-1) !== content) this.promptHistory.push(content)
    if (this.promptHistory.length > 50) this.promptHistory.shift()
    this.historyCursor = this.promptHistory.length
    this.historyDraft = ""
  }

  private restorePromptHistory(messages: HistoryMessage[], prepend = false): void {
    const restored = messages
      .filter((message) => message.role === "user")
      .map((message) => message.content.trim())
      .filter(Boolean)
    const combined = prepend ? [...restored, ...this.promptHistory] : restored
    const compacted = combined.filter((content, index) => content !== combined[index - 1])
    this.promptHistory.splice(0, this.promptHistory.length, ...compacted.slice(-50))
    this.historyCursor = this.promptHistory.length
    this.historyDraft = ""
  }

  private closeSessions(): void {
    this.sessionLoadId += 1
    this.sessionLoading = false
    this.sessionMenu.hide()
    this.renderTitleColor()
    this.clearComposer()
    this.syncComposerPlaceholder()
    this.composer.focus()
    if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
    this.updateMeta()
  }

  private async openContext(): Promise<void> {
    this.commandMenu.hide()
    this.sessionMenu.hide()
    this.mentionMenu.hide()
    this.branchMenu.hide()
    this.clearComposer()
    this.status.content = "Reading agent context…"
    try {
      const context = await fetchSessionContext(
        this.options.apiUrl,
        this.options.apiToken,
        this.client.activeChatId,
        this.apiReauthenticator,
      )
      if (!context) {
        this.status.content = "Context unavailable · new session or older gateway"
        return
      }
      this.contextTokens = context.estimatedSessionTokens
      this.lastUsage = context.lastUsage
      this.updateTitle()
      this.contextPanel.show(context)
      this.status.content = "Context snapshot"
      this.updateMeta()
    } catch (error) {
      this.status.content = error instanceof Error ? error.message : String(error)
    }
  }

  private async refreshSessionMetadata(chatId: string): Promise<void> {
    if (!this.options.apiUrl || !this.options.apiToken) return
    const requestId = ++this.sessionMetadataId
    try {
      const sessions = await fetchSessions(
        this.options.apiUrl,
        this.options.apiToken,
        this.apiReauthenticator,
      )
      if (
        requestId !== this.sessionMetadataId
        || chatId !== this.client.activeChatId
      ) return
      const session = sessions.find((candidate) => candidate.chatId === chatId)
      if (!session) return
      this.sessionTitle = sessionLabel(session)
      this.applySessionModel(session)
      this.applySessionScope(session)
      this.updateTitle()
    } catch {
      // Session metadata is decorative; conversation transport stays authoritative.
    }
  }

  private async refreshContextEstimate(chatId: string): Promise<void> {
    try {
      const context = await fetchSessionContext(
        this.options.apiUrl,
        this.options.apiToken,
        chatId,
        this.apiReauthenticator,
      )
      if (!context || chatId !== this.client.activeChatId) return
      this.contextTokens = context.estimatedSessionTokens
      this.lastUsage = context.lastUsage || this.lastUsage
      this.updateTitle()
      if (!this.activeTurn) this.status.content = this.readyStatus()
      this.updateMeta()
    } catch {
      // Keep the last known estimate; it is intentionally informational.
    }
  }

  private openDiff(): void {
    this.commandMenu.hide()
    this.sessionMenu.hide()
    this.mentionMenu.hide()
    this.branchMenu.hide()
    this.contextPanel.hide()
    this.clearComposer()
    this.composer.blur()
    const edits = this.currentFileEdits.length ? this.currentFileEdits : this.lastFileEdits
    this.diffViewer.show(edits)
    this.diffViewer.resize(this.renderer.width)
    this.status.content = edits.length ? "Last turn diff" : "No file changes in the last turn"
    this.updateMeta()
  }

  private async loadOlderHistory(): Promise<void> {
    if (
      this.historyLoadingOlder
      || !this.historyHasMore
      || !this.historyBeforeCursor
      || !this.client.activeChatId
    ) return
    const hydrationId = this.hydrationId
    const chatId = this.client.activeChatId
    this.historyLoadingOlder = true
    this.status.content = "Loading earlier messages…"
    try {
      const history = await fetchHistory(
        this.options.apiUrl,
        this.options.apiToken,
        chatId,
        this.historyBeforeCursor,
        this.apiReauthenticator,
      )
      if (hydrationId !== this.hydrationId || chatId !== this.client.activeChatId) return
      await this.transcript.prependHistory(history.messages)
      this.restorePromptHistory(history.messages, true)
      this.historyBeforeCursor = history.beforeCursor
      this.historyHasMore = history.hasMoreBefore
      this.status.content = history.hasMoreBefore
        ? `${history.messages.length} earlier messages · PageUp for more`
        : "Start of session"
    } catch (error) {
      if (hydrationId !== this.hydrationId) return
      this.status.content = error instanceof Error ? error.message : String(error)
    } finally {
      if (hydrationId === this.hydrationId) this.historyLoadingOlder = false
    }
  }

  private async copySelection(text: string): Promise<void> {
    if (!text) return
    try {
      if (!this.renderer.copyToClipboardOSC52(text)) await copyWithSystemClipboard(text)
      this.renderer.clearSelection()
      this.status.content = "Copied"
    } catch {
      this.status.content = "Copy unavailable"
    }
  }

  private quit(): void {
    if (this.quitting) return
    this.quitting = true
    this.submitGeneration += 1
    this.submitPending = false
    this.host.release()
    this.client.close()
    this.renderer.destroy()
  }

  private handleDestroy = (): void => {
    if (this.shimmerTimer) clearInterval(this.shimmerTimer)
    this.transcript.destroy()
    this.diffViewer.destroy()
    this.host.release()
    this.client.close()
  }
}

export type { AppOptions }
