/**
 * Interactive mode for the coding agent.
 * Handles TUI rendering and user interaction, delegating business logic to AgentSession.
 */

import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type {
  AssistantMessage,
  ImageContent,
  Message,
  Model,
  OAuthProviderId,
} from "@mariozechner/pi-ai";
import type {
  AutocompleteItem,
  EditorAction,
  EditorComponent,
  EditorTheme,
  KeyId,
  MarkdownTheme,
  OverlayHandle,
  OverlayOptions,
  SlashCommand,
} from "@mariozechner/pi-tui";
import {
  CombinedAutocompleteProvider,
  type Component,
  Container,
  fuzzyFilter,
  Loader,
  Markdown,
  matchesKey,
  ProcessTerminal,
  Spacer,
  Text,
  TruncatedText,
  TUI,
  visibleWidth,
} from "@mariozechner/pi-tui";
import { spawn, spawnSync } from "child_process";
import {
  APP_NAME,
  getAuthPath,
  getDebugLogPath,
  getShareViewerUrl,
  getUpdateInstruction,
  VERSION,
} from "../../config.js";
import {
  type AgentSession,
  type AgentSessionEvent,
  parseSkillBlock,
} from "../../core/agent-session.js";
import type { CompactionResult } from "../../core/compaction/index.js";
import type {
  ExtensionContext,
  ExtensionRunner,
  ExtensionUIContext,
  ExtensionUIDialogOptions,
  ExtensionWidgetOptions,
} from "../../core/extensions/index.js";
import {
  FooterDataProvider,
  type ReadonlyFooterDataProvider,
} from "../../core/footer-data-provider.js";
import { type AppAction, KeybindingsManager } from "../../core/keybindings.js";
import { createCompactionSummaryMessage } from "../../core/messages.js";
import { resolveModelScope } from "../../core/model-resolver.js";
import type { ResourceDiagnostic } from "../../core/resource-loader.js";
import {
  type SessionContext,
  SessionManager,
} from "../../core/session-manager.js";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
import type { TruncationResult } from "../../core/tools/truncate.js";
import {
  getChangelogPath,
  getNewEntries,
  parseChangelog,
} from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import {
  extensionForImageMimeType,
  readClipboardImage,
} from "../../utils/clipboard-image.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js";
import { BashExecutionComponent } from "./components/bash-execution.js";
import { BorderedLoader } from "./components/bordered-loader.js";
import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js";
import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js";
import { CustomEditor } from "./components/custom-editor.js";
import { CustomMessageComponent } from "./components/custom-message.js";
import { DaxnutsComponent } from "./components/daxnuts.js";
import { DynamicBorder } from "./components/dynamic-border.js";
import { ExtensionEditorComponent } from "./components/extension-editor.js";
import { ExtensionInputComponent } from "./components/extension-input.js";
import { ExtensionSelectorComponent } from "./components/extension-selector.js";
import { FooterComponent } from "./components/footer.js";
import {
  appKey,
  appKeyHint,
  editorKey,
  keyHint,
  rawKeyHint,
} from "./components/keybinding-hints.js";
import { LoginDialogComponent } from "./components/login-dialog.js";
import { ModelSelectorComponent } from "./components/model-selector.js";
import { OAuthSelectorComponent } from "./components/oauth-selector.js";
import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";
import { SessionSelectorComponent } from "./components/session-selector.js";
import { SettingsSelectorComponent } from "./components/settings-selector.js";
import { SkillInvocationMessageComponent } from "./components/skill-invocation-message.js";
import { ToolExecutionComponent } from "./components/tool-execution.js";
import { TreeSelectorComponent } from "./components/tree-selector.js";
import { UserMessageComponent } from "./components/user-message.js";
import { UserMessageSelectorComponent } from "./components/user-message-selector.js";
import {
  getAvailableThemes,
  getAvailableThemesWithPaths,
  getEditorTheme,
  getMarkdownTheme,
  getThemeByName,
  initTheme,
  onThemeChange,
  setRegisteredThemes,
  setTheme,
  setThemeInstance,
  Theme,
  type ThemeColor,
  theme,
} from "./theme/theme.js";

/** Interface for components that can be expanded/collapsed */
interface Expandable {
  setExpanded(expanded: boolean): void;
}

function isExpandable(obj: unknown): obj is Expandable {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "setExpanded" in obj &&
    typeof obj.setExpanded === "function"
  );
}

type CompactionQueuedMessage = {
  text: string;
  mode: "steer" | "followUp";
};

/**
 * Options for InteractiveMode initialization.
 */
export interface InteractiveModeOptions {
  /** Providers that were migrated to auth.json (shows warning) */
  migratedProviders?: string[];
  /** Warning message if session model couldn't be restored */
  modelFallbackMessage?: string;
  /** Initial message to send on startup (can include @file content) */
  initialMessage?: string;
  /** Images to attach to the initial message */
  initialImages?: ImageContent[];
  /** Additional messages to send after the initial message */
  initialMessages?: string[];
  /** Force verbose startup (overrides quietStartup setting) */
  verbose?: boolean;
}

export class InteractiveMode {
  private session: AgentSession;
  private ui: TUI;
  private chatContainer: Container;
  private pendingMessagesContainer: Container;
  private statusContainer: Container;
  private defaultEditor: CustomEditor;
  private editor: EditorComponent;
  private autocompleteProvider: CombinedAutocompleteProvider | undefined;
  private fdPath: string | undefined;
  private editorContainer: Container;
  private footer: FooterComponent;
  private footerDataProvider: FooterDataProvider;
  private keybindings: KeybindingsManager;
  private version: string;
  private isInitialized = false;
  private onInputCallback?: (text: string) => void;
  private loadingAnimation: Loader | undefined = undefined;
  private pendingWorkingMessage: string | undefined = undefined;
  private readonly defaultWorkingMessage = "Working...";

  private lastSigintTime = 0;
  private lastEscapeTime = 0;
  private changelogMarkdown: string | undefined = undefined;

  // Status line tracking (for mutating immediately-sequential status updates)
  private lastStatusSpacer: Spacer | undefined = undefined;
  private lastStatusText: Text | undefined = undefined;

  // Streaming message tracking
  private streamingComponent: AssistantMessageComponent | undefined = undefined;
  private streamingMessage: AssistantMessage | undefined = undefined;

  // Tool execution tracking: toolCallId -> component
  private pendingTools = new Map<string, ToolExecutionComponent>();

  // Tool output expansion state
  private toolOutputExpanded = false;

  // Thinking block visibility state
  private hideThinkingBlock = false;

  // Skill commands: command name -> skill file path
  private skillCommands = new Map<string, string>();

  // Agent subscription unsubscribe function
  private unsubscribe?: () => void;

  // Track if editor is in bash mode (text starts with !)
  private isBashMode = false;

  // Track current bash execution component
  private bashComponent: BashExecutionComponent | undefined = undefined;

  // Track pending bash components (shown in pending area, moved to chat on submit)
  private pendingBashComponents: BashExecutionComponent[] = [];

  // Auto-compaction state
  private autoCompactionLoader: Loader | undefined = undefined;
  private autoCompactionEscapeHandler?: () => void;

  // Auto-retry state
  private retryLoader: Loader | undefined = undefined;
  private retryEscapeHandler?: () => void;

  // Messages queued while compaction is running
  private compactionQueuedMessages: CompactionQueuedMessage[] = [];

  // Shutdown state
  private shutdownRequested = false;

  // Extension UI state
  private extensionSelector: ExtensionSelectorComponent | undefined = undefined;
  private extensionInput: ExtensionInputComponent | undefined = undefined;
  private extensionEditor: ExtensionEditorComponent | undefined = undefined;
  private extensionTerminalInputUnsubscribers = new Set<() => void>();

  // Extension widgets (components rendered above/below the editor)
  private extensionWidgetsAbove = new Map<
    string,
    Component & { dispose?(): void }
  >();
  private extensionWidgetsBelow = new Map<
    string,
    Component & { dispose?(): void }
  >();
  private widgetContainerAbove!: Container;
  private widgetContainerBelow!: Container;

  // Custom footer from extension (undefined = use built-in footer)
  private customFooter: (Component & { dispose?(): void }) | undefined =
    undefined;

  // Header container that holds the built-in or custom header
  private headerContainer: Container;

  // Built-in header (logo + keybinding hints + changelog)
  private builtInHeader: Component | undefined = undefined;

  // Custom header from extension (undefined = use built-in header)
  private customHeader: (Component & { dispose?(): void }) | undefined =
    undefined;

  // Convenience accessors
  private get agent() {
    return this.session.agent;
  }
  private get sessionManager() {
    return this.session.sessionManager;
  }
  private get settingsManager() {
    return this.session.settingsManager;
  }

  constructor(
    session: AgentSession,
    private options: InteractiveModeOptions = {},
  ) {
    this.session = session;
    this.version = VERSION;
    this.ui = new TUI(
      new ProcessTerminal(),
      this.settingsManager.getShowHardwareCursor(),
    );
    this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
    this.headerContainer = new Container();
    this.chatContainer = new Container();
    this.pendingMessagesContainer = new Container();
    this.statusContainer = new Container();
    this.widgetContainerAbove = new Container();
    this.widgetContainerBelow = new Container();
    this.keybindings = KeybindingsManager.create();
    const editorPaddingX = this.settingsManager.getEditorPaddingX();
    const autocompleteMaxVisible =
      this.settingsManager.getAutocompleteMaxVisible();
    this.defaultEditor = new CustomEditor(
      this.ui,
      getEditorTheme(),
      this.keybindings,
      {
        paddingX: editorPaddingX,
        autocompleteMaxVisible,
      },
    );
    this.editor = this.defaultEditor;
    this.editorContainer = new Container();
    this.editorContainer.addChild(this.editor as Component);
    this.footerDataProvider = new FooterDataProvider();
    this.footer = new FooterComponent(session, this.footerDataProvider);
    this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);

    // Load hide thinking block setting
    this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();

    // Register themes from resource loader and initialize
    setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
    initTheme(this.settingsManager.getTheme(), true);
  }

  private setupAutocomplete(fdPath: string | undefined): void {
    // Define commands for autocomplete
    const slashCommands: SlashCommand[] = BUILTIN_SLASH_COMMANDS.map(
      (command) => ({
        name: command.name,
        description: command.description,
      }),
    );

    const modelCommand = slashCommands.find(
      (command) => command.name === "model",
    );
    if (modelCommand) {
      modelCommand.getArgumentCompletions = (
        prefix: string,
      ): AutocompleteItem[] | null => {
        // Get available models (scoped or from registry)
        const models =
          this.session.scopedModels.length > 0
            ? this.session.scopedModels.map((s) => s.model)
            : this.session.modelRegistry.getAvailable();

        if (models.length === 0) return null;

        // Create items with provider/id format
        const items = models.map((m) => ({
          id: m.id,
          provider: m.provider,
          label: `${m.provider}/${m.id}`,
        }));

        // Fuzzy filter by model ID + provider (allows "opus anthropic" to match)
        const filtered = fuzzyFilter(
          items,
          prefix,
          (item) => `${item.id} ${item.provider}`,
        );

        if (filtered.length === 0) return null;

        return filtered.map((item) => ({
          value: item.label,
          label: item.id,
          description: item.provider,
        }));
      };
    }

    // Convert prompt templates to SlashCommand format for autocomplete
    const templateCommands: SlashCommand[] = this.session.promptTemplates.map(
      (cmd) => ({
        name: cmd.name,
        description: cmd.description,
      }),
    );

    // Convert extension commands to SlashCommand format
    const builtinCommandNames = new Set(slashCommands.map((c) => c.name));
    const extensionCommands: SlashCommand[] = (
      this.session.extensionRunner?.getRegisteredCommands(
        builtinCommandNames,
      ) ?? []
    ).map((cmd) => ({
      name: cmd.name,
      description: cmd.description ?? "(extension command)",
      getArgumentCompletions: cmd.getArgumentCompletions,
    }));

    // Build skill commands from session.skills (if enabled)
    this.skillCommands.clear();
    const skillCommandList: SlashCommand[] = [];
    if (this.settingsManager.getEnableSkillCommands()) {
      for (const skill of this.session.resourceLoader.getSkills().skills) {
        const commandName = `skill:${skill.name}`;
        this.skillCommands.set(commandName, skill.filePath);
        skillCommandList.push({
          name: commandName,
          description: skill.description,
        });
      }
    }

    // Setup autocomplete
    this.autocompleteProvider = new CombinedAutocompleteProvider(
      [
        ...slashCommands,
        ...templateCommands,
        ...extensionCommands,
        ...skillCommandList,
      ],
      process.cwd(),
      fdPath,
    );
    this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
    if (this.editor !== this.defaultEditor) {
      this.editor.setAutocompleteProvider?.(this.autocompleteProvider);
    }
  }

  async init(): Promise<void> {
    if (this.isInitialized) return;

    // Load changelog (only show new entries, skip for resumed sessions)
    this.changelogMarkdown = this.getChangelogForDisplay();

    // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
    // Both are needed: fd for autocomplete, rg for grep tool and bash commands
    const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]);
    this.fdPath = fdPath;

    // Add header container as first child
    this.ui.addChild(this.headerContainer);

    // Add header with keybindings from config (unless silenced)
    if (this.options.verbose || !this.settingsManager.getQuietStartup()) {
      const logo =
        theme.bold(theme.fg("accent", APP_NAME)) +
        theme.fg("dim", ` v${this.version}`);

      // Build startup instructions using keybinding hint helpers
      const kb = this.keybindings;
      const hint = (action: AppAction, desc: string) =>
        appKeyHint(kb, action, desc);

      const instructions = [
        hint("interrupt", "to interrupt"),
        hint("clear", "to clear"),
        rawKeyHint(`${appKey(kb, "clear")} twice`, "to exit"),
        hint("exit", "to exit (empty)"),
        hint("suspend", "to suspend"),
        keyHint("deleteToLineEnd", "to delete to end"),
        hint("cycleThinkingLevel", "to cycle thinking level"),
        rawKeyHint(
          `${appKey(kb, "cycleModelForward")}/${appKey(kb, "cycleModelBackward")}`,
          "to cycle models",
        ),
        hint("selectModel", "to select model"),
        hint("expandTools", "to expand tools"),
        hint("toggleThinking", "to expand thinking"),
        hint("externalEditor", "for external editor"),
        rawKeyHint("/", "for commands"),
        rawKeyHint("!", "to run bash"),
        rawKeyHint("!!", "to run bash (no context)"),
        hint("followUp", "to queue follow-up"),
        hint("dequeue", "to edit all queued messages"),
        hint("pasteImage", "to paste image"),
        rawKeyHint("drop files", "to attach"),
      ].join("\n");
      this.builtInHeader = new Text(`${logo}\n${instructions}`, 1, 0);

      // Setup UI layout
      this.headerContainer.addChild(new Spacer(1));
      this.headerContainer.addChild(this.builtInHeader);
      this.headerContainer.addChild(new Spacer(1));

      // Add changelog if provided
      if (this.changelogMarkdown) {
        this.headerContainer.addChild(new DynamicBorder());
        if (this.settingsManager.getCollapseChangelog()) {
          const versionMatch = this.changelogMarkdown.match(
            /##\s+\[?(\d+\.\d+\.\d+)\]?/,
          );
          const latestVersion = versionMatch ? versionMatch[1] : this.version;
          const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
          this.headerContainer.addChild(new Text(condensedText, 1, 0));
        } else {
          this.headerContainer.addChild(
            new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0),
          );
          this.headerContainer.addChild(new Spacer(1));
          this.headerContainer.addChild(
            new Markdown(
              this.changelogMarkdown.trim(),
              1,
              0,
              this.getMarkdownThemeWithSettings(),
            ),
          );
          this.headerContainer.addChild(new Spacer(1));
        }
        this.headerContainer.addChild(new DynamicBorder());
      }
    } else {
      // Minimal header when silenced
      this.builtInHeader = new Text("", 0, 0);
      this.headerContainer.addChild(this.builtInHeader);
      if (this.changelogMarkdown) {
        // Still show changelog notification even in silent mode
        this.headerContainer.addChild(new Spacer(1));
        const versionMatch = this.changelogMarkdown.match(
          /##\s+\[?(\d+\.\d+\.\d+)\]?/,
        );
        const latestVersion = versionMatch ? versionMatch[1] : this.version;
        const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
        this.headerContainer.addChild(new Text(condensedText, 1, 0));
      }
    }

    this.ui.addChild(this.chatContainer);
    this.ui.addChild(this.pendingMessagesContainer);
    this.ui.addChild(this.statusContainer);
    this.renderWidgets(); // Initialize with default spacer
    this.ui.addChild(this.widgetContainerAbove);
    this.ui.addChild(this.editorContainer);
    this.ui.addChild(this.widgetContainerBelow);
    this.ui.addChild(this.footer);
    this.ui.setFocus(this.editor);

    this.setupKeyHandlers();
    this.setupEditorSubmitHandler();

    // Initialize extensions first so resources are shown before messages
    await this.initExtensions();

    // Render initial messages AFTER showing loaded resources
    this.renderInitialMessages();

    // Start the UI
    this.ui.start();
    this.isInitialized = true;

    // Set terminal title
    this.updateTerminalTitle();

    // Subscribe to agent events
    this.subscribeToAgent();

    // Set up theme file watcher
    onThemeChange(() => {
      this.ui.invalidate();
      this.updateEditorBorderColor();
      this.ui.requestRender();
    });

    // Set up git branch watcher (uses provider instead of footer)
    this.footerDataProvider.onBranchChange(() => {
      this.ui.requestRender();
    });

    // Initialize available provider count for footer display
    await this.updateAvailableProviderCount();
  }

  /**
   * Update terminal title with session name and cwd.
   */
  private updateTerminalTitle(): void {
    const cwdBasename = path.basename(process.cwd());
    const sessionName = this.sessionManager.getSessionName();
    if (sessionName) {
      this.ui.terminal.setTitle(`π - ${sessionName} - ${cwdBasename}`);
    } else {
      this.ui.terminal.setTitle(`π - ${cwdBasename}`);
    }
  }

  /**
   * Run the interactive mode. This is the main entry point.
   * Initializes the UI, shows warnings, processes initial messages, and starts the interactive loop.
   */
  async run(): Promise<void> {
    await this.init();

    // Start version check asynchronously
    this.checkForNewVersion().then((newVersion) => {
      if (newVersion) {
        this.showNewVersionNotification(newVersion);
      }
    });

    // Show startup warnings
    const {
      migratedProviders,
      modelFallbackMessage,
      initialMessage,
      initialImages,
      initialMessages,
    } = this.options;

    if (migratedProviders && migratedProviders.length > 0) {
      this.showWarning(
        `Migrated credentials to auth.json: ${migratedProviders.join(", ")}`,
      );
    }

    const modelsJsonError = this.session.modelRegistry.getError();
    if (modelsJsonError) {
      this.showError(`models.json error: ${modelsJsonError}`);
    }

    if (modelFallbackMessage) {
      this.showWarning(modelFallbackMessage);
    }

    // Process initial messages
    if (initialMessage) {
      try {
        await this.session.prompt(initialMessage, { images: initialImages });
      } catch (error: unknown) {
        const errorMessage =
          error instanceof Error ? error.message : "Unknown error occurred";
        this.showError(errorMessage);
      }
    }

    if (initialMessages) {
      for (const message of initialMessages) {
        try {
          await this.session.prompt(message);
        } catch (error: unknown) {
          const errorMessage =
            error instanceof Error ? error.message : "Unknown error occurred";
          this.showError(errorMessage);
        }
      }
    }

    // Main interactive loop
    while (true) {
      const userInput = await this.getUserInput();
      try {
        await this.session.prompt(userInput);
      } catch (error: unknown) {
        const errorMessage =
          error instanceof Error ? error.message : "Unknown error occurred";
        this.showError(errorMessage);
      }
    }
  }

  /**
   * Check npm registry for a newer version.
   */
  private async checkForNewVersion(): Promise<string | undefined> {
    if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE)
      return undefined;

    try {
      const response = await fetch(
        "https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest",
        {
          signal: AbortSignal.timeout(10000),
        },
      );
      if (!response.ok) return undefined;

      const data = (await response.json()) as { version?: string };
      const latestVersion = data.version;

      if (latestVersion && latestVersion !== this.version) {
        return latestVersion;
      }

      return undefined;
    } catch {
      return undefined;
    }
  }

  /**
   * Get changelog entries to display on startup.
   * Only shows new entries since last seen version, skips for resumed sessions.
   */
  private getChangelogForDisplay(): string | undefined {
    // Skip changelog for resumed/continued sessions (already have messages)
    if (this.session.state.messages.length > 0) {
      return undefined;
    }

    const lastVersion = this.settingsManager.getLastChangelogVersion();
    const changelogPath = getChangelogPath();
    const entries = parseChangelog(changelogPath);

    if (!lastVersion) {
      // Fresh install - just record the version, don't show changelog
      this.settingsManager.setLastChangelogVersion(VERSION);
      return undefined;
    } else {
      const newEntries = getNewEntries(entries, lastVersion);
      if (newEntries.length > 0) {
        this.settingsManager.setLastChangelogVersion(VERSION);
        return newEntries.map((e) => e.content).join("\n\n");
      }
    }

    return undefined;
  }

  private getMarkdownThemeWithSettings(): MarkdownTheme {
    return {
      ...getMarkdownTheme(),
      codeBlockIndent: this.settingsManager.getCodeBlockIndent(),
    };
  }

  // =========================================================================
  // Extension System
  // =========================================================================

  private formatDisplayPath(p: string): string {
    const home = os.homedir();
    let result = p;

    // Replace home directory with ~
    if (result.startsWith(home)) {
      result = `~${result.slice(home.length)}`;
    }

    return result;
  }

  /**
   * Get a short path relative to the package root for display.
   */
  private getShortPath(fullPath: string, source: string): string {
    // For npm packages, show path relative to node_modules/pkg/
    const npmMatch = fullPath.match(
      /node_modules\/(@?[^/]+(?:\/[^/]+)?)\/(.*)/,
    );
    if (npmMatch && source.startsWith("npm:")) {
      return npmMatch[2];
    }

    // For git packages, show path relative to repo root
    const gitMatch = fullPath.match(/git\/[^/]+\/[^/]+\/(.*)/);
    if (gitMatch && source.startsWith("git:")) {
      return gitMatch[1];
    }

    // For local/auto, just use formatDisplayPath
    return this.formatDisplayPath(fullPath);
  }

  private getDisplaySourceInfo(
    source: string,
    scope: string,
  ): { label: string; scopeLabel?: string; color: "accent" | "muted" } {
    if (source === "local") {
      if (scope === "user") {
        return { label: "user", color: "muted" };
      }
      if (scope === "project") {
        return { label: "project", color: "muted" };
      }
      if (scope === "temporary") {
        return { label: "path", scopeLabel: "temp", color: "muted" };
      }
      return { label: "path", color: "muted" };
    }

    if (source === "cli") {
      return {
        label: "path",
        scopeLabel: scope === "temporary" ? "temp" : undefined,
        color: "muted",
      };
    }

    const scopeLabel =
      scope === "user"
        ? "user"
        : scope === "project"
          ? "project"
          : scope === "temporary"
            ? "temp"
            : undefined;
    return { label: source, scopeLabel, color: "accent" };
  }

  private getScopeGroup(
    source: string,
    scope: string,
  ): "user" | "project" | "path" {
    if (source === "cli" || scope === "temporary") return "path";
    if (scope === "user") return "user";
    if (scope === "project") return "project";
    return "path";
  }

  private isPackageSource(source: string): boolean {
    return source.startsWith("npm:") || source.startsWith("git:");
  }

  private buildScopeGroups(
    paths: string[],
    metadata: Map<string, { source: string; scope: string; origin: string }>,
  ): Array<{
    scope: "user" | "project" | "path";
    paths: string[];
    packages: Map<string, string[]>;
  }> {
    const groups: Record<
      "user" | "project" | "path",
      {
        scope: "user" | "project" | "path";
        paths: string[];
        packages: Map<string, string[]>;
      }
    > = {
      user: { scope: "user", paths: [], packages: new Map() },
      project: { scope: "project", paths: [], packages: new Map() },
      path: { scope: "path", paths: [], packages: new Map() },
    };

    for (const p of paths) {
      const meta = this.findMetadata(p, metadata);
      const source = meta?.source ?? "local";
      const scope = meta?.scope ?? "project";
      const groupKey = this.getScopeGroup(source, scope);
      const group = groups[groupKey];

      if (this.isPackageSource(source)) {
        const list = group.packages.get(source) ?? [];
        list.push(p);
        group.packages.set(source, list);
      } else {
        group.paths.push(p);
      }
    }

    return [groups.project, groups.user, groups.path].filter(
      (group) => group.paths.length > 0 || group.packages.size > 0,
    );
  }

  private formatScopeGroups(
    groups: Array<{
      scope: "user" | "project" | "path";
      paths: string[];
      packages: Map<string, string[]>;
    }>,
    options: {
      formatPath: (p: string) => string;
      formatPackagePath: (p: string, source: string) => string;
    },
  ): string {
    const lines: string[] = [];

    for (const group of groups) {
      lines.push(`  ${theme.fg("accent", group.scope)}`);

      const sortedPaths = [...group.paths].sort((a, b) => a.localeCompare(b));
      for (const p of sortedPaths) {
        lines.push(theme.fg("dim", `    ${options.formatPath(p)}`));
      }

      const sortedPackages = Array.from(group.packages.entries()).sort(
        ([a], [b]) => a.localeCompare(b),
      );
      for (const [source, paths] of sortedPackages) {
        lines.push(`    ${theme.fg("mdLink", source)}`);
        const sortedPackagePaths = [...paths].sort((a, b) =>
          a.localeCompare(b),
        );
        for (const p of sortedPackagePaths) {
          lines.push(
            theme.fg("dim", `      ${options.formatPackagePath(p, source)}`),
          );
        }
      }
    }

    return lines.join("\n");
  }

  /**
   * Find metadata for a path, checking parent directories if exact match fails.
   * Package manager stores metadata for directories, but we display file paths.
   */
  private findMetadata(
    p: string,
    metadata: Map<string, { source: string; scope: string; origin: string }>,
  ): { source: string; scope: string; origin: string } | undefined {
    // Try exact match first
    const exact = metadata.get(p);
    if (exact) return exact;

    // Try parent directories (package manager stores directory paths)
    let current = p;
    while (current.includes("/")) {
      current = current.substring(0, current.lastIndexOf("/"));
      const parent = metadata.get(current);
      if (parent) return parent;
    }

    return undefined;
  }

  /**
   * Format a path with its source/scope info from metadata.
   */
  private formatPathWithSource(
    p: string,
    metadata: Map<string, { source: string; scope: string; origin: string }>,
  ): string {
    const meta = this.findMetadata(p, metadata);
    if (meta) {
      const shortPath = this.getShortPath(p, meta.source);
      const { label, scopeLabel } = this.getDisplaySourceInfo(
        meta.source,
        meta.scope,
      );
      const labelText = scopeLabel ? `${label} (${scopeLabel})` : label;
      return `${labelText} ${shortPath}`;
    }
    return this.formatDisplayPath(p);
  }

  /**
   * Format resource diagnostics with nice collision display using metadata.
   */
  private formatDiagnostics(
    diagnostics: readonly ResourceDiagnostic[],
    metadata: Map<string, { source: string; scope: string; origin: string }>,
  ): string {
    const lines: string[] = [];

    // Group collision diagnostics by name
    const collisions = new Map<string, ResourceDiagnostic[]>();
    const otherDiagnostics: ResourceDiagnostic[] = [];

    for (const d of diagnostics) {
      if (d.type === "collision" && d.collision) {
        const list = collisions.get(d.collision.name) ?? [];
        list.push(d);
        collisions.set(d.collision.name, list);
      } else {
        otherDiagnostics.push(d);
      }
    }

    // Format collision diagnostics grouped by name
    for (const [name, collisionList] of collisions) {
      const first = collisionList[0]?.collision;
      if (!first) continue;
      lines.push(theme.fg("warning", `  "${name}" collision:`));
      // Show winner
      lines.push(
        theme.fg(
          "dim",
          `    ${theme.fg("success", "✓")} ${this.formatPathWithSource(first.winnerPath, metadata)}`,
        ),
      );
      // Show all losers
      for (const d of collisionList) {
        if (d.collision) {
          lines.push(
            theme.fg(
              "dim",
              `    ${theme.fg("warning", "✗")} ${this.formatPathWithSource(d.collision.loserPath, metadata)} (skipped)`,
            ),
          );
        }
      }
    }

    // Format other diagnostics (skill name collisions, parse errors, etc.)
    for (const d of otherDiagnostics) {
      if (d.path) {
        // Use metadata-aware formatting for paths
        const sourceInfo = this.formatPathWithSource(d.path, metadata);
        lines.push(
          theme.fg(d.type === "error" ? "error" : "warning", `  ${sourceInfo}`),
        );
        lines.push(
          theme.fg(
            d.type === "error" ? "error" : "warning",
            `    ${d.message}`,
          ),
        );
      } else {
        lines.push(
          theme.fg(d.type === "error" ? "error" : "warning", `  ${d.message}`),
        );
      }
    }

    return lines.join("\n");
  }

  private showLoadedResources(options?: {
    extensionPaths?: string[];
    force?: boolean;
    showDiagnosticsWhenQuiet?: boolean;
  }): void {
    const showListing =
      options?.force ||
      this.options.verbose ||
      !this.settingsManager.getQuietStartup();
    const showDiagnostics =
      showListing || options?.showDiagnosticsWhenQuiet === true;
    if (!showListing && !showDiagnostics) {
      return;
    }

    const metadata = this.session.resourceLoader.getPathMetadata();
    const sectionHeader = (name: string, color: ThemeColor = "mdHeading") =>
      theme.fg(color, `[${name}]`);

    const skillsResult = this.session.resourceLoader.getSkills();
    const promptsResult = this.session.resourceLoader.getPrompts();
    const themesResult = this.session.resourceLoader.getThemes();

    if (showListing) {
      const contextFiles =
        this.session.resourceLoader.getAgentsFiles().agentsFiles;
      if (contextFiles.length > 0) {
        this.chatContainer.addChild(new Spacer(1));
        const contextList = contextFiles
          .map((f) => theme.fg("dim", `  ${this.formatDisplayPath(f.path)}`))
          .join("\n");
        this.chatContainer.addChild(
          new Text(`${sectionHeader("Context")}\n${contextList}`, 0, 0),
        );
        this.chatContainer.addChild(new Spacer(1));
      }

      const skills = skillsResult.skills;
      if (skills.length > 0) {
        const skillPaths = skills.map((s) => s.filePath);
        const groups = this.buildScopeGroups(skillPaths, metadata);
        const skillList = this.formatScopeGroups(groups, {
          formatPath: (p) => this.formatDisplayPath(p),
          formatPackagePath: (p, source) => this.getShortPath(p, source),
        });
        this.chatContainer.addChild(
          new Text(`${sectionHeader("Skills")}\n${skillList}`, 0, 0),
        );
        this.chatContainer.addChild(new Spacer(1));
      }

      const templates = this.session.promptTemplates;
      if (templates.length > 0) {
        const templatePaths = templates.map((t) => t.filePath);
        const groups = this.buildScopeGroups(templatePaths, metadata);
        const templateByPath = new Map(templates.map((t) => [t.filePath, t]));
        const templateList = this.formatScopeGroups(groups, {
          formatPath: (p) => {
            const template = templateByPath.get(p);
            return template ? `/${template.name}` : this.formatDisplayPath(p);
          },
          formatPackagePath: (p) => {
            const template = templateByPath.get(p);
            return template ? `/${template.name}` : this.formatDisplayPath(p);
          },
        });
        this.chatContainer.addChild(
          new Text(`${sectionHeader("Prompts")}\n${templateList}`, 0, 0),
        );
        this.chatContainer.addChild(new Spacer(1));
      }

      const extensionPaths = options?.extensionPaths ?? [];
      if (extensionPaths.length > 0) {
        const groups = this.buildScopeGroups(extensionPaths, metadata);
        const extList = this.formatScopeGroups(groups, {
          formatPath: (p) => this.formatDisplayPath(p),
          formatPackagePath: (p, source) => this.getShortPath(p, source),
        });
        this.chatContainer.addChild(
          new Text(
            `${sectionHeader("Extensions", "mdHeading")}\n${extList}`,
            0,
            0,
          ),
        );
        this.chatContainer.addChild(new Spacer(1));
      }

      // Show loaded themes (excluding built-in)
      const loadedThemes = themesResult.themes;
      const customThemes = loadedThemes.filter((t) => t.sourcePath);
      if (customThemes.length > 0) {
        const themePaths = customThemes.map((t) => t.sourcePath!);
        const groups = this.buildScopeGroups(themePaths, metadata);
        const themeList = this.formatScopeGroups(groups, {
          formatPath: (p) => this.formatDisplayPath(p),
          formatPackagePath: (p, source) => this.getShortPath(p, source),
        });
        this.chatContainer.addChild(
          new Text(`${sectionHeader("Themes")}\n${themeList}`, 0, 0),
        );
        this.chatContainer.addChild(new Spacer(1));
      }
    }

    if (showDiagnostics) {
      const skillDiagnostics = skillsResult.diagnostics;
      if (skillDiagnostics.length > 0) {
        const warningLines = this.formatDiagnostics(skillDiagnostics, metadata);
        this.chatContainer.addChild(
          new Text(
            `${theme.fg("warning", "[Skill conflicts]")}\n${warningLines}`,
            0,
            0,
          ),
        );
        this.chatContainer.addChild(new Spacer(1));
      }

      const promptDiagnostics = promptsResult.diagnostics;
      if (promptDiagnostics.length > 0) {
        const warningLines = this.formatDiagnostics(
          promptDiagnostics,
          metadata,
        );
        this.chatContainer.addChild(
          new Text(
            `${theme.fg("warning", "[Prompt conflicts]")}\n${warningLines}`,
            0,
            0,
          ),
        );
        this.chatContainer.addChild(new Spacer(1));
      }

      const extensionDiagnostics: ResourceDiagnostic[] = [];
      const extensionErrors =
        this.session.resourceLoader.getExtensions().errors;
      if (extensionErrors.length > 0) {
        for (const error of extensionErrors) {
          extensionDiagnostics.push({
            type: "error",
            message: error.error,
            path: error.path,
          });
        }
      }

      const commandDiagnostics =
        this.session.extensionRunner?.getCommandDiagnostics() ?? [];
      extensionDiagnostics.push(...commandDiagnostics);

      const shortcutDiagnostics =
        this.session.extensionRunner?.getShortcutDiagnostics() ?? [];
      extensionDiagnostics.push(...shortcutDiagnostics);

      if (extensionDiagnostics.length > 0) {
        const warningLines = this.formatDiagnostics(
          extensionDiagnostics,
          metadata,
        );
        this.chatContainer.addChild(
          new Text(
            `${theme.fg("warning", "[Extension issues]")}\n${warningLines}`,
            0,
            0,
          ),
        );
        this.chatContainer.addChild(new Spacer(1));
      }

      const themeDiagnostics = themesResult.diagnostics;
      if (themeDiagnostics.length > 0) {
        const warningLines = this.formatDiagnostics(themeDiagnostics, metadata);
        this.chatContainer.addChild(
          new Text(
            `${theme.fg("warning", "[Theme conflicts]")}\n${warningLines}`,
            0,
            0,
          ),
        );
        this.chatContainer.addChild(new Spacer(1));
      }
    }
  }

  /**
   * Initialize the extension system with TUI-based UI context.
   */
  private async initExtensions(): Promise<void> {
    const uiContext = this.createExtensionUIContext();
    await this.session.bindExtensions({
      uiContext,
      commandContextActions: {
        waitForIdle: () => this.session.agent.waitForIdle(),
        newSession: async (options) => {
          if (this.loadingAnimation) {
            this.loadingAnimation.stop();
            this.loadingAnimation = undefined;
          }
          this.statusContainer.clear();

          // Delegate to AgentSession (handles setup + agent state sync)
          const success = await this.session.newSession(options);
          if (!success) {
            return { cancelled: true };
          }

          // Clear UI state
          this.chatContainer.clear();
          this.pendingMessagesContainer.clear();
          this.compactionQueuedMessages = [];
          this.streamingComponent = undefined;
          this.streamingMessage = undefined;
          this.pendingTools.clear();

          // Render any messages added via setup, or show empty session
          this.renderInitialMessages();
          this.ui.requestRender();

          return { cancelled: false };
        },
        fork: async (entryId) => {
          const result = await this.session.fork(entryId);
          if (result.cancelled) {
            return { cancelled: true };
          }

          this.chatContainer.clear();
          this.renderInitialMessages();
          this.editor.setText(result.selectedText);
          this.showStatus("Forked to new session");

          return { cancelled: false };
        },
        navigateTree: async (targetId, options) => {
          const result = await this.session.navigateTree(targetId, {
            summarize: options?.summarize,
            customInstructions: options?.customInstructions,
            replaceInstructions: options?.replaceInstructions,
            label: options?.label,
          });
          if (result.cancelled) {
            return { cancelled: true };
          }

          this.chatContainer.clear();
          this.renderInitialMessages();
          if (result.editorText && !this.editor.getText().trim()) {
            this.editor.setText(result.editorText);
          }
          this.showStatus("Navigated to selected point");

          return { cancelled: false };
        },
        switchSession: async (sessionPath) => {
          await this.handleResumeSession(sessionPath);
          return { cancelled: false };
        },
        reload: async () => {
          await this.handleReloadCommand();
        },
      },
      shutdownHandler: () => {
        this.shutdownRequested = true;
        if (!this.session.isStreaming) {
          void this.shutdown();
        }
      },
      onError: (error) => {
        this.showExtensionError(error.extensionPath, error.error, error.stack);
      },
    });

    setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
    this.setupAutocomplete(this.fdPath);

    const extensionRunner = this.session.extensionRunner;
    if (!extensionRunner) {
      this.showLoadedResources({ extensionPaths: [], force: false });
      return;
    }

    this.setupExtensionShortcuts(extensionRunner);
    this.showLoadedResources({
      extensionPaths: extensionRunner.getExtensionPaths(),
      force: false,
    });
  }

  /**
   * Get a registered tool definition by name (for custom rendering).
   */
  private getRegisteredToolDefinition(toolName: string) {
    const tools = this.session.extensionRunner?.getAllRegisteredTools() ?? [];
    const registeredTool = tools.find((t) => t.definition.name === toolName);
    return registeredTool?.definition;
  }

  /**
   * Set up keyboard shortcuts registered by extensions.
   */
  private setupExtensionShortcuts(extensionRunner: ExtensionRunner): void {
    const shortcuts = extensionRunner.getShortcuts(
      this.keybindings.getEffectiveConfig(),
    );
    if (shortcuts.size === 0) return;

    // Create a context for shortcut handlers
    const createContext = (): ExtensionContext => ({
      ui: this.createExtensionUIContext(),
      hasUI: true,
      cwd: process.cwd(),
      sessionManager: this.sessionManager,
      modelRegistry: this.session.modelRegistry,
      model: this.session.model,
      isIdle: () => !this.session.isStreaming,
      abort: () => this.session.abort(),
      hasPendingMessages: () => this.session.pendingMessageCount > 0,
      shutdown: () => {
        this.shutdownRequested = true;
      },
      getContextUsage: () => this.session.getContextUsage(),
      compact: (options) => {
        void (async () => {
          try {
            const result = await this.executeCompaction(
              options?.customInstructions,
              false,
            );
            if (result) {
              options?.onComplete?.(result);
            }
          } catch (error) {
            const err =
              error instanceof Error ? error : new Error(String(error));
            options?.onError?.(err);
          }
        })();
      },
      getSystemPrompt: () => this.session.systemPrompt,
    });

    // Set up the extension shortcut handler on the default editor
    this.defaultEditor.onExtensionShortcut = (data: string) => {
      for (const [shortcutStr, shortcut] of shortcuts) {
        // Cast to KeyId - extension shortcuts use the same format
        if (matchesKey(data, shortcutStr as KeyId)) {
          // Run handler async, don't block input
          Promise.resolve(shortcut.handler(createContext())).catch((err) => {
            this.showError(
              `Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`,
            );
          });
          return true;
        }
      }
      return false;
    };
  }

  /**
   * Set extension status text in the footer.
   */
  private setExtensionStatus(key: string, text: string | undefined): void {
    this.footerDataProvider.setExtensionStatus(key, text);
    this.ui.requestRender();
  }

  /**
   * Set an extension widget (string array or custom component).
   */
  private setExtensionWidget(
    key: string,
    content:
      | string[]
      | ((tui: TUI, thm: Theme) => Component & { dispose?(): void })
      | undefined,
    options?: ExtensionWidgetOptions,
  ): void {
    const placement = options?.placement ?? "aboveEditor";
    const removeExisting = (
      map: Map<string, Component & { dispose?(): void }>,
    ) => {
      const existing = map.get(key);
      if (existing?.dispose) existing.dispose();
      map.delete(key);
    };

    removeExisting(this.extensionWidgetsAbove);
    removeExisting(this.extensionWidgetsBelow);

    if (content === undefined) {
      this.renderWidgets();
      return;
    }

    let component: Component & { dispose?(): void };

    if (Array.isArray(content)) {
      // Wrap string array in a Container with Text components
      const container = new Container();
      for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) {
        container.addChild(new Text(line, 1, 0));
      }
      if (content.length > InteractiveMode.MAX_WIDGET_LINES) {
        container.addChild(
          new Text(theme.fg("muted", "... (widget truncated)"), 1, 0),
        );
      }
      component = container;
    } else {
      // Factory function - create component
      component = content(this.ui, theme);
    }

    const targetMap =
      placement === "belowEditor"
        ? this.extensionWidgetsBelow
        : this.extensionWidgetsAbove;
    targetMap.set(key, component);
    this.renderWidgets();
  }

  private clearExtensionWidgets(): void {
    for (const widget of this.extensionWidgetsAbove.values()) {
      widget.dispose?.();
    }
    for (const widget of this.extensionWidgetsBelow.values()) {
      widget.dispose?.();
    }
    this.extensionWidgetsAbove.clear();
    this.extensionWidgetsBelow.clear();
    this.renderWidgets();
  }

  private resetExtensionUI(): void {
    if (this.extensionSelector) {
      this.hideExtensionSelector();
    }
    if (this.extensionInput) {
      this.hideExtensionInput();
    }
    if (this.extensionEditor) {
      this.hideExtensionEditor();
    }
    this.ui.hideOverlay();
    this.clearExtensionTerminalInputListeners();
    this.setExtensionFooter(undefined);
    this.setExtensionHeader(undefined);
    this.clearExtensionWidgets();
    this.footerDataProvider.clearExtensionStatuses();
    this.footer.invalidate();
    this.setCustomEditorComponent(undefined);
    this.defaultEditor.onExtensionShortcut = undefined;
    this.updateTerminalTitle();
    if (this.loadingAnimation) {
      this.loadingAnimation.setMessage(
        `${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`,
      );
    }
  }

  // Maximum total widget lines to prevent viewport overflow
  private static readonly MAX_WIDGET_LINES = 10;

  /**
   * Render all extension widgets to the widget container.
   */
  private renderWidgets(): void {
    if (!this.widgetContainerAbove || !this.widgetContainerBelow) return;
    this.renderWidgetContainer(
      this.widgetContainerAbove,
      this.extensionWidgetsAbove,
      true,
      true,
    );
    this.renderWidgetContainer(
      this.widgetContainerBelow,
      this.extensionWidgetsBelow,
      false,
      false,
    );
    this.ui.requestRender();
  }

  private renderWidgetContainer(
    container: Container,
    widgets: Map<string, Component & { dispose?(): void }>,
    spacerWhenEmpty: boolean,
    leadingSpacer: boolean,
  ): void {
    container.clear();

    if (widgets.size === 0) {
      if (spacerWhenEmpty) {
        container.addChild(new Spacer(1));
      }
      return;
    }

    if (leadingSpacer) {
      container.addChild(new Spacer(1));
    }
    for (const component of widgets.values()) {
      container.addChild(component);
    }
  }

  /**
   * Set a custom footer component, or restore the built-in footer.
   */
  private setExtensionFooter(
    factory:
      | ((
          tui: TUI,
          thm: Theme,
          footerData: ReadonlyFooterDataProvider,
        ) => Component & { dispose?(): void })
      | undefined,
  ): void {
    // Dispose existing custom footer
    if (this.customFooter?.dispose) {
      this.customFooter.dispose();
    }

    // Remove current footer from UI
    if (this.customFooter) {
      this.ui.removeChild(this.customFooter);
    } else {
      this.ui.removeChild(this.footer);
    }

    if (factory) {
      // Create and add custom footer, passing the data provider
      this.customFooter = factory(this.ui, theme, this.footerDataProvider);
      this.ui.addChild(this.customFooter);
    } else {
      // Restore built-in footer
      this.customFooter = undefined;
      this.ui.addChild(this.footer);
    }

    this.ui.requestRender();
  }

  /**
   * Set a custom header component, or restore the built-in header.
   */
  private setExtensionHeader(
    factory:
      | ((tui: TUI, thm: Theme) => Component & { dispose?(): void })
      | undefined,
  ): void {
    // Header may not be initialized yet if called during early initialization
    if (!this.builtInHeader) {
      return;
    }

    // Dispose existing custom header
    if (this.customHeader?.dispose) {
      this.customHeader.dispose();
    }

    // Find the index of the current header in the header container
    const currentHeader = this.customHeader || this.builtInHeader;
    const index = this.headerContainer.children.indexOf(currentHeader);

    if (factory) {
      // Create and add custom header
      this.customHeader = factory(this.ui, theme);
      if (index !== -1) {
        this.headerContainer.children[index] = this.customHeader;
      } else {
        // If not found (e.g. builtInHeader was never added), add at the top
        this.headerContainer.children.unshift(this.customHeader);
      }
    } else {
      // Restore built-in header
      this.customHeader = undefined;
      if (index !== -1) {
        this.headerContainer.children[index] = this.builtInHeader;
      }
    }

    this.ui.requestRender();
  }

  private addExtensionTerminalInputListener(
    handler: (data: string) => { consume?: boolean; data?: string } | undefined,
  ): () => void {
    const unsubscribe = this.ui.addInputListener(handler);
    this.extensionTerminalInputUnsubscribers.add(unsubscribe);
    return () => {
      unsubscribe();
      this.extensionTerminalInputUnsubscribers.delete(unsubscribe);
    };
  }

  private clearExtensionTerminalInputListeners(): void {
    for (const unsubscribe of this.extensionTerminalInputUnsubscribers) {
      unsubscribe();
    }
    this.extensionTerminalInputUnsubscribers.clear();
  }

  /**
   * Create the ExtensionUIContext for extensions.
   */
  private createExtensionUIContext(): ExtensionUIContext {
    return {
      select: (title, options, opts) =>
        this.showExtensionSelector(title, options, opts),
      confirm: (title, message, opts) =>
        this.showExtensionConfirm(title, message, opts),
      input: (title, placeholder, opts) =>
        this.showExtensionInput(title, placeholder, opts),
      notify: (message, type) => this.showExtensionNotify(message, type),
      onTerminalInput: (handler) =>
        this.addExtensionTerminalInputListener(handler),
      setStatus: (key, text) => this.setExtensionStatus(key, text),
      setWorkingMessage: (message) => {
        if (this.loadingAnimation) {
          if (message) {
            this.loadingAnimation.setMessage(message);
          } else {
            this.loadingAnimation.setMessage(
              `${this.defaultWorkingMessage} (${appKey(this.keybindings, "interrupt")} to interrupt)`,
            );
          }
        } else {
          // Queue message for when loadingAnimation is created (handles agent_start race)
          this.pendingWorkingMessage = message;
        }
      },
      setWidget: (key, content, options) =>
        this.setExtensionWidget(key, content, options),
      setFooter: (factory) => this.setExtensionFooter(factory),
      setHeader: (factory) => this.setExtensionHeader(factory),
      setTitle: (title) => this.ui.terminal.setTitle(title),
      custom: (factory, options) => this.showExtensionCustom(factory, options),
      pasteToEditor: (text) =>
        this.editor.handleInput(`\x1b[200~${text}\x1b[201~`),
      setEditorText: (text) => this.editor.setText(text),
      getEditorText: () => this.editor.getText(),
      editor: (title, prefill) => this.showExtensionEditor(title, prefill),
      setEditorComponent: (factory) => this.setCustomEditorComponent(factory),
      get theme() {
        return theme;
      },
      getAllThemes: () => getAvailableThemesWithPaths(),
      getTheme: (name) => getThemeByName(name),
      setTheme: (themeOrName) => {
        if (themeOrName instanceof Theme) {
          setThemeInstance(themeOrName);
          this.ui.requestRender();
          return { success: true };
        }
        const result = setTheme(themeOrName, true);
        if (result.success) {
          if (this.settingsManager.getTheme() !== themeOrName) {
            this.settingsManager.setTheme(themeOrName);
          }
          this.ui.requestRender();
        }
        return result;
      },
      getToolsExpanded: () => this.toolOutputExpanded,
      setToolsExpanded: (expanded) => this.setToolsExpanded(expanded),
    };
  }

  /**
   * Show a selector for extensions.
   */
  private showExtensionSelector(
    title: string,
    options: string[],
    opts?: ExtensionUIDialogOptions,
  ): Promise<string | undefined> {
    return new Promise((resolve) => {
      if (opts?.signal?.aborted) {
        resolve(undefined);
        return;
      }

      const onAbort = () => {
        this.hideExtensionSelector();
        resolve(undefined);
      };
      opts?.signal?.addEventListener("abort", onAbort, { once: true });

      this.extensionSelector = new ExtensionSelectorComponent(
        title,
        options,
        (option) => {
          opts?.signal?.removeEventListener("abort", onAbort);
          this.hideExtensionSelector();
          resolve(option);
        },
        () => {
          opts?.signal?.removeEventListener("abort", onAbort);
          this.hideExtensionSelector();
          resolve(undefined);
        },
        { tui: this.ui, timeout: opts?.timeout },
      );

      this.editorContainer.clear();
      this.editorContainer.addChild(this.extensionSelector);
      this.ui.setFocus(this.extensionSelector);
      this.ui.requestRender();
    });
  }

  /**
   * Hide the extension selector.
   */
  private hideExtensionSelector(): void {
    this.extensionSelector?.dispose();
    this.editorContainer.clear();
    this.editorContainer.addChild(this.editor);
    this.extensionSelector = undefined;
    this.ui.setFocus(this.editor);
    this.ui.requestRender();
  }

  /**
   * Show a confirmation dialog for extensions.
   */
  private async showExtensionConfirm(
    title: string,
    message: string,
    opts?: ExtensionUIDialogOptions,
  ): Promise<boolean> {
    const result = await this.showExtensionSelector(
      `${title}\n${message}`,
      ["Yes", "No"],
      opts,
    );
    return result === "Yes";
  }

  /**
   * Show a text input for extensions.
   */
  private showExtensionInput(
    title: string,
    placeholder?: string,
    opts?: ExtensionUIDialogOptions,
  ): Promise<string | undefined> {
    return new Promise((resolve) => {
      if (opts?.signal?.aborted) {
        resolve(undefined);
        return;
      }

      const onAbort = () => {
        this.hideExtensionInput();
        resolve(undefined);
      };
      opts?.signal?.addEventListener("abort", onAbort, { once: true });

      this.extensionInput = new ExtensionInputComponent(
        title,
        placeholder,
        (value) => {
          opts?.signal?.removeEventListener("abort", onAbort);
          this.hideExtensionInput();
          resolve(value);
        },
        () => {
          opts?.signal?.removeEventListener("abort", onAbort);
          this.hideExtensionInput();
          resolve(undefined);
        },
        { tui: this.ui, timeout: opts?.timeout },
      );

      this.editorContainer.clear();
      this.editorContainer.addChild(this.extensionInput);
      this.ui.setFocus(this.extensionInput);
      this.ui.requestRender();
    });
  }

  /**
   * Hide the extension input.
   */
  private hideExtensionInput(): void {
    this.extensionInput?.dispose();
    this.editorContainer.clear();
    this.editorContainer.addChild(this.editor);
    this.extensionInput = undefined;
    this.ui.setFocus(this.editor);
    this.ui.requestRender();
  }

  /**
   * Show a multi-line editor for extensions (with Ctrl+G support).
   */
  private showExtensionEditor(
    title: string,
    prefill?: string,
  ): Promise<string | undefined> {
    return new Promise((resolve) => {
      this.extensionEditor = new ExtensionEditorComponent(
        this.ui,
        this.keybindings,
        title,
        prefill,
        (value) => {
          this.hideExtensionEditor();
          resolve(value);
        },
        () => {
          this.hideExtensionEditor();
          resolve(undefined);
        },
      );

      this.editorContainer.clear();
      this.editorContainer.addChild(this.extensionEditor);
      this.ui.setFocus(this.extensionEditor);
      this.ui.requestRender();
    });
  }

  /**
   * Hide the extension editor.
   */
  private hideExtensionEditor(): void {
    this.editorContainer.clear();
    this.editorContainer.addChild(this.editor);
    this.extensionEditor = undefined;
    this.ui.setFocus(this.editor);
    this.ui.requestRender();
  }

  /**
   * Set a custom editor component from an extension.
   * Pass undefined to restore the default editor.
   */
  private setCustomEditorComponent(
    factory:
      | ((
          tui: TUI,
          theme: EditorTheme,
          keybindings: KeybindingsManager,
        ) => EditorComponent)
      | undefined,
  ): void {
    // Save text from current editor before switching
    const currentText = this.editor.getText();

    this.editorContainer.clear();

    if (factory) {
      // Create the custom editor with tui, theme, and keybindings
      const newEditor = factory(this.ui, getEditorTheme(), this.keybindings);

      // Wire up callbacks from the default editor
      newEditor.onSubmit = this.defaultEditor.onSubmit;
      newEditor.onChange = this.defaultEditor.onChange;

      // Copy text from previous editor
      newEditor.setText(currentText);

      // Copy appearance settings if supported
      if (newEditor.borderColor !== undefined) {
        newEditor.borderColor = this.defaultEditor.borderColor;
      }
      if (newEditor.setPaddingX !== undefined) {
        newEditor.setPaddingX(this.defaultEditor.getPaddingX());
      }

      // Set autocomplete if supported
      if (newEditor.setAutocompleteProvider && this.autocompleteProvider) {
        newEditor.setAutocompleteProvider(this.autocompleteProvider);
      }

      // If extending CustomEditor, copy app-level handlers
      // Use duck typing since instanceof fails across jiti module boundaries
      const customEditor = newEditor as unknown as Record<string, unknown>;
      if (
        "actionHandlers" in customEditor &&
        customEditor.actionHandlers instanceof Map
      ) {
        customEditor.onEscape = () => this.defaultEditor.onEscape?.();
        customEditor.onCtrlD = () => this.defaultEditor.onCtrlD?.();
        customEditor.onPasteImage = () => this.defaultEditor.onPasteImage?.();
        customEditor.onExtensionShortcut = (data: string) =>
          this.defaultEditor.onExtensionShortcut?.(data);
        // Copy action handlers (clear, suspend, model switching, etc.)
        for (const [action, handler] of this.defaultEditor.actionHandlers) {
          (customEditor.actionHandlers as Map<string, () => void>).set(
            action,
            handler,
          );
        }
      }

      this.editor = newEditor;
    } else {
      // Restore default editor with text from custom editor
      this.defaultEditor.setText(currentText);
      this.editor = this.defaultEditor;
    }

    this.editorContainer.addChild(this.editor as Component);
    this.ui.setFocus(this.editor as Component);
    this.ui.requestRender();
  }

  /**
   * Show a notification for extensions.
   */
  private showExtensionNotify(
    message: string,
    type?: "info" | "warning" | "error",
  ): void {
    if (type === "error") {
      this.showError(message);
    } else if (type === "warning") {
      this.showWarning(message);
    } else {
      this.showStatus(message);
    }
  }

  /** Show a custom component with keyboard focus. Overlay mode renders on top of existing content. */
  private async showExtensionCustom<T>(
    factory: (
      tui: TUI,
      theme: Theme,
      keybindings: KeybindingsManager,
      done: (result: T) => void,
    ) =>
      | (Component & { dispose?(): void })
      | Promise<Component & { dispose?(): void }>,
    options?: {
      overlay?: boolean;
      overlayOptions?: OverlayOptions | (() => OverlayOptions);
      onHandle?: (handle: OverlayHandle) => void;
    },
  ): Promise<T> {
    const savedText = this.editor.getText();
    const isOverlay = options?.overlay ?? false;

    const restoreEditor = () => {
      this.editorContainer.clear();
      this.editorContainer.addChild(this.editor);
      this.editor.setText(savedText);
      this.ui.setFocus(this.editor);
      this.ui.requestRender();
    };

    return new Promise((resolve, reject) => {
      let component: Component & { dispose?(): void };
      let closed = false;

      const close = (result: T) => {
        if (closed) return;
        closed = true;
        if (isOverlay) this.ui.hideOverlay();
        else restoreEditor();
        // Note: both branches above already call requestRender
        resolve(result);
        try {
          component?.dispose?.();
        } catch {
          /* ignore dispose errors */
        }
      };

      Promise.resolve(factory(this.ui, theme, this.keybindings, close))
        .then((c) => {
          if (closed) return;
          component = c;
          if (isOverlay) {
            // Resolve overlay options - can be static or dynamic function
            const resolveOptions = (): OverlayOptions | undefined => {
              if (options?.overlayOptions) {
                const opts =
                  typeof options.overlayOptions === "function"
                    ? options.overlayOptions()
                    : options.overlayOptions;
                return opts;
              }
              // Fallback: use component's width property if available
              const w = (component as { width?: number }).width;
              return w ? { width: w } : undefined;
            };
            const handle = this.ui.showOverlay(component, resolveOptions());
            // Expose handle to caller for visibility control
            options?.onHandle?.(handle);
          } else {
            this.editorContainer.clear();
            this.editorContainer.addChild(component);
            this.ui.setFocus(component);
            this.ui.requestRender();
          }
        })
        .catch((err) => {
          if (closed) return;
          if (!isOverlay) restoreEditor();
          reject(err);
        });
    });
  }

  /**
   * Show an extension error in the UI.
   */
  private showExtensionError(
    extensionPath: string,
    error: string,
    stack?: string,
  ): void {
    const errorMsg = `Extension "${extensionPath}" error: ${error}`;
    const errorText = new Text(theme.fg("error", errorMsg), 1, 0);
    this.chatContainer.addChild(errorText);
    if (stack) {
      // Show stack trace in dim color, indented
      const stackLines = stack
        .split("\n")
        .slice(1) // Skip first line (duplicates error message)
        .map((line) => theme.fg("dim", `  ${line.trim()}`))
        .join("\n");
      if (stackLines) {
        this.chatContainer.addChild(new Text(stackLines, 1, 0));
      }
    }
    this.ui.requestRender();
  }

  // =========================================================================
  // Key Handlers
  // =========================================================================

  private setupKeyHandlers(): void {
    // Set up handlers on defaultEditor - they use this.editor for text access
    // so they work correctly regardless of which editor is active
    this.defaultEditor.onEscape = () => {
      if (this.loadingAnimation) {
        this.restoreQueuedMessagesToEditor({ abort: true });
      } else if (this.session.isBashRunning) {
        this.session.abortBash();
      } else if (this.isBashMode) {
        this.editor.setText("");
        this.isBashMode = false;
        this.updateEditorBorderColor();
      } else if (!this.editor.getText().trim()) {
        // Double-escape with empty editor triggers /tree, /fork, or nothing based on setting
        const action = this.settingsManager.getDoubleEscapeAction();
        if (action !== "none") {
          const now = Date.now();
          if (now - this.lastEscapeTime < 500) {
            if (action === "tree") {
              this.showTreeSelector();
            } else {
              this.showUserMessageSelector();
            }
            this.lastEscapeTime = 0;
          } else {
            this.lastEscapeTime = now;
          }
        }
      }
    };

    // Register app action handlers
    this.defaultEditor.onAction("clear", () => this.handleCtrlC());
    this.defaultEditor.onCtrlD = () => this.handleCtrlD();
    this.defaultEditor.onAction("suspend", () => this.handleCtrlZ());
    this.defaultEditor.onAction("cycleThinkingLevel", () =>
      this.cycleThinkingLevel(),
    );
    this.defaultEditor.onAction("cycleModelForward", () =>
      this.cycleModel("forward"),
    );
    this.defaultEditor.onAction("cycleModelBackward", () =>
      this.cycleModel("backward"),
    );

    // Global debug handler on TUI (works regardless of focus)
    this.ui.onDebug = () => this.handleDebugCommand();
    this.defaultEditor.onAction("selectModel", () => this.showModelSelector());
    this.defaultEditor.onAction("expandTools", () =>
      this.toggleToolOutputExpansion(),
    );
    this.defaultEditor.onAction("toggleThinking", () =>
      this.toggleThinkingBlockVisibility(),
    );
    this.defaultEditor.onAction("externalEditor", () =>
      this.openExternalEditor(),
    );
    this.defaultEditor.onAction("followUp", () => this.handleFollowUp());
    this.defaultEditor.onAction("dequeue", () => this.handleDequeue());
    this.defaultEditor.onAction("newSession", () => this.handleClearCommand());
    this.defaultEditor.onAction("tree", () => this.showTreeSelector());
    this.defaultEditor.onAction("fork", () => this.showUserMessageSelector());
    this.defaultEditor.onAction("resume", () => this.showSessionSelector());

    this.defaultEditor.onChange = (text: string) => {
      const wasBashMode = this.isBashMode;
      this.isBashMode = text.trimStart().startsWith("!");
      if (wasBashMode !== this.isBashMode) {
        this.updateEditorBorderColor();
      }
    };

    // Handle clipboard image paste (triggered on Ctrl+V)
    this.defaultEditor.onPasteImage = () => {
      this.handleClipboardImagePaste();
    };
  }

  private async handleClipboardImagePaste(): Promise<void> {
    try {
      const image = await readClipboardImage();
      if (!image) {
        return;
      }

      // Write to temp file
      const tmpDir = os.tmpdir();
      const ext = extensionForImageMimeType(image.mimeType) ?? "png";
      const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
      const filePath = path.join(tmpDir, fileName);
      fs.writeFileSync(filePath, Buffer.from(image.bytes));

      // Insert file path directly
      this.editor.insertTextAtCursor?.(filePath);
      this.ui.requestRender();
    } catch {
      // Silently ignore clipboard errors (may not have permission, etc.)
    }
  }

  private setupEditorSubmitHandler(): void {
    this.defaultEditor.onSubmit = async (text: string) => {
      text = text.trim();
      if (!text) return;

      // Handle commands
      if (text === "/settings") {
        this.showSettingsSelector();
        this.editor.setText("");
        return;
      }
      if (text === "/scoped-models") {
        this.editor.setText("");
        await this.showModelsSelector();
        return;
      }
      if (text === "/model" || text.startsWith("/model ")) {
        const searchTerm = text.startsWith("/model ")
          ? text.slice(7).trim()
          : undefined;
        this.editor.setText("");
        await this.handleModelCommand(searchTerm);
        return;
      }
      if (text.startsWith("/export")) {
        await this.handleExportCommand(text);
        this.editor.setText("");
        return;
      }
      if (text === "/share") {
        await this.handleShareCommand();
        this.editor.setText("");
        return;
      }
      if (text === "/copy") {
        this.handleCopyCommand();
        this.editor.setText("");
        return;
      }
      if (text === "/name" || text.startsWith("/name ")) {
        this.handleNameCommand(text);
        this.editor.setText("");
        return;
      }
      if (text === "/session") {
        this.handleSessionCommand();
        this.editor.setText("");
        return;
      }
      if (text === "/changelog") {
        this.handleChangelogCommand();
        this.editor.setText("");
        return;
      }
      if (text === "/hotkeys") {
        this.handleHotkeysCommand();
        this.editor.setText("");
        return;
      }
      if (text === "/fork") {
        this.showUserMessageSelector();
        this.editor.setText("");
        return;
      }
      if (text === "/tree") {
        this.showTreeSelector();
        this.editor.setText("");
        return;
      }
      if (text === "/login") {
        this.showOAuthSelector("login");
        this.editor.setText("");
        return;
      }
      if (text === "/logout") {
        this.showOAuthSelector("logout");
        this.editor.setText("");
        return;
      }
      if (text === "/new") {
        this.editor.setText("");
        await this.handleClearCommand();
        return;
      }
      if (text === "/compact" || text.startsWith("/compact ")) {
        const customInstructions = text.startsWith("/compact ")
          ? text.slice(9).trim()
          : undefined;
        this.editor.setText("");
        await this.handleCompactCommand(customInstructions);
        return;
      }
      if (text === "/reload") {
        this.editor.setText("");
        await this.handleReloadCommand();
        return;
      }
      if (text === "/debug") {
        this.handleDebugCommand();
        this.editor.setText("");
        return;
      }
      if (text === "/arminsayshi") {
        this.handleArminSaysHi();
        this.editor.setText("");
        return;
      }
      if (text === "/resume") {
        this.showSessionSelector();
        this.editor.setText("");
        return;
      }
      if (text === "/quit") {
        this.editor.setText("");
        await this.shutdown();
        return;
      }

      // Handle bash command (! for normal, !! for excluded from context)
      if (text.startsWith("!")) {
        const isExcluded = text.startsWith("!!");
        const command = isExcluded
          ? text.slice(2).trim()
          : text.slice(1).trim();
        if (command) {
          if (this.session.isBashRunning) {
            this.showWarning(
              "A bash command is already running. Press Esc to cancel it first.",
            );
            this.editor.setText(text);
            return;
          }
          this.editor.addToHistory?.(text);
          await this.handleBashCommand(command, isExcluded);
          this.isBashMode = false;
          this.updateEditorBorderColor();
          return;
        }
      }

      // Queue input during compaction (extension commands execute immediately)
      if (this.session.isCompacting) {
        if (this.isExtensionCommand(text)) {
          this.editor.addToHistory?.(text);
          this.editor.setText("");
          await this.session.prompt(text);
        } else {
          this.queueCompactionMessage(text, "steer");
        }
        return;
      }

      // If streaming, use prompt() with steer behavior
      // This handles extension commands (execute immediately), prompt template expansion, and queueing
      if (this.session.isStreaming) {
        this.editor.addToHistory?.(text);
        this.editor.setText("");
        await this.session.prompt(text, { streamingBehavior: "steer" });
        this.updatePendingMessagesDisplay();
        this.ui.requestRender();
        return;
      }

      // Normal message submission
      // First, move any pending bash components to chat
      this.flushPendingBashComponents();

      if (this.onInputCallback) {
        this.onInputCallback(text);
      }
      this.editor.addToHistory?.(text);
    };
  }

  private subscribeToAgent(): void {
    this.unsubscribe = this.session.subscribe(async (event) => {
      await this.handleEvent(event);
    });
  }

  private async handleEvent(event: AgentSessionEvent): Promise<void> {
    if (!this.isInitialized) {
      await this.init();
    }

    this.footer.invalidate();

    switch (event.type) {
      case "agent_start":
        // Restore main escape handler if retry handler is still active
        // (retry success event fires later, but we need main handler now)
        if (this.retryEscapeHandler) {
          this.defaultEditor.onEscape = this.retryEscapeHandler;
          this.retryEscapeHandler = undefined;
        }
        if (this.retryLoader) {
          this.retryLoader.stop();
          this.retryLoader = undefined;
        }
        if (this.loadingAnimation) {
          this.loadingAnimation.stop();
        }
        this.statusContainer.clear();
        this.loadingAnimation = new Loader(
          this.ui,
          (spinner) => theme.fg("accent", spinner),
          (text) => theme.fg("muted", text),
          this.defaultWorkingMessage,
        );
        this.statusContainer.addChild(this.loadingAnimation);
        // Apply any pending working message queued before loader existed
        if (this.pendingWorkingMessage !== undefined) {
          if (this.pendingWorkingMessage) {
            this.loadingAnimation.setMessage(this.pendingWorkingMessage);
          }
          this.pendingWorkingMessage = undefined;
        }
        this.ui.requestRender();
        break;

      case "message_start":
        if (event.message.role === "custom") {
          this.addMessageToChat(event.message);
          this.ui.requestRender();
        } else if (event.message.role === "user") {
          this.addMessageToChat(event.message);
          this.updatePendingMessagesDisplay();
          this.ui.requestRender();
        } else if (event.message.role === "assistant") {
          this.streamingComponent = new AssistantMessageComponent(
            undefined,
            this.hideThinkingBlock,
            this.getMarkdownThemeWithSettings(),
          );
          this.streamingMessage = event.message;
          this.chatContainer.addChild(this.streamingComponent);
          this.streamingComponent.updateContent(this.streamingMessage);
          this.ui.requestRender();
        }
        break;

      case "message_update":
        if (this.streamingComponent && event.message.role === "assistant") {
          this.streamingMessage = event.message;
          this.streamingComponent.updateContent(this.streamingMessage);

          for (const content of this.streamingMessage.content) {
            if (content.type === "toolCall") {
              if (!this.pendingTools.has(content.id)) {
                const component = new ToolExecutionComponent(
                  content.name,
                  content.arguments,
                  {
                    showImages: this.settingsManager.getShowImages(),
                  },
                  this.getRegisteredToolDefinition(content.name),
                  this.ui,
                );
                component.setExpanded(this.toolOutputExpanded);
                this.chatContainer.addChild(component);
                this.pendingTools.set(content.id, component);
              } else {
                const component = this.pendingTools.get(content.id);
                if (component) {
                  component.updateArgs(content.arguments);
                }
              }
            }
          }
          this.ui.requestRender();
        }
        break;

      case "message_end":
        if (event.message.role === "user") break;
        if (this.streamingComponent && event.message.role === "assistant") {
          this.streamingMessage = event.message;
          let errorMessage: string | undefined;
          if (this.streamingMessage.stopReason === "aborted") {
            const retryAttempt = this.session.retryAttempt;
            errorMessage =
              retryAttempt > 0
                ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
                : "Operation aborted";
            this.streamingMessage.errorMessage = errorMessage;
          }
          this.streamingComponent.updateContent(this.streamingMessage);

          if (
            this.streamingMessage.stopReason === "aborted" ||
            this.streamingMessage.stopReason === "error"
          ) {
            if (!errorMessage) {
              errorMessage = this.streamingMessage.errorMessage || "Error";
            }
            for (const [, component] of this.pendingTools.entries()) {
              component.updateResult({
                content: [{ type: "text", text: errorMessage }],
                isError: true,
              });
            }
            this.pendingTools.clear();
          } else {
            // Args are now complete - trigger diff computation for edit tools
            for (const [, component] of this.pendingTools.entries()) {
              component.setArgsComplete();
            }
          }
          this.streamingComponent = undefined;
          this.streamingMessage = undefined;
          this.footer.invalidate();
        }
        this.ui.requestRender();
        break;

      case "tool_execution_start": {
        if (!this.pendingTools.has(event.toolCallId)) {
          const component = new ToolExecutionComponent(
            event.toolName,
            event.args,
            {
              showImages: this.settingsManager.getShowImages(),
            },
            this.getRegisteredToolDefinition(event.toolName),
            this.ui,
          );
          component.setExpanded(this.toolOutputExpanded);
          this.chatContainer.addChild(component);
          this.pendingTools.set(event.toolCallId, component);
          this.ui.requestRender();
        }
        break;
      }

      case "tool_execution_update": {
        const component = this.pendingTools.get(event.toolCallId);
        if (component) {
          component.updateResult(
            { ...event.partialResult, isError: false },
            true,
          );
          this.ui.requestRender();
        }
        break;
      }

      case "tool_execution_end": {
        const component = this.pendingTools.get(event.toolCallId);
        if (component) {
          component.updateResult({ ...event.result, isError: event.isError });
          this.pendingTools.delete(event.toolCallId);
          this.ui.requestRender();
        }
        break;
      }

      case "agent_end":
        if (this.loadingAnimation) {
          this.loadingAnimation.stop();
          this.loadingAnimation = undefined;
          this.statusContainer.clear();
        }
        if (this.streamingComponent) {
          this.chatContainer.removeChild(this.streamingComponent);
          this.streamingComponent = undefined;
          this.streamingMessage = undefined;
        }
        this.pendingTools.clear();

        await this.checkShutdownRequested();

        this.ui.requestRender();
        break;

      case "auto_compaction_start": {
        // Keep editor active; submissions are queued during compaction.
        // Set up escape to abort auto-compaction
        this.autoCompactionEscapeHandler = this.defaultEditor.onEscape;
        this.defaultEditor.onEscape = () => {
          this.session.abortCompaction();
        };
        // Show compacting indicator with reason
        this.statusContainer.clear();
        const reasonText =
          event.reason === "overflow" ? "Context overflow detected, " : "";
        this.autoCompactionLoader = new Loader(
          this.ui,
          (spinner) => theme.fg("accent", spinner),
          (text) => theme.fg("muted", text),
          `${reasonText}Auto-compacting... (${appKey(this.keybindings, "interrupt")} to cancel)`,
        );
        this.statusContainer.addChild(this.autoCompactionLoader);
        this.ui.requestRender();
        break;
      }

      case "auto_compaction_end": {
        // Restore escape handler
        if (this.autoCompactionEscapeHandler) {
          this.defaultEditor.onEscape = this.autoCompactionEscapeHandler;
          this.autoCompactionEscapeHandler = undefined;
        }
        // Stop loader
        if (this.autoCompactionLoader) {
          this.autoCompactionLoader.stop();
          this.autoCompactionLoader = undefined;
          this.statusContainer.clear();
        }
        // Handle result
        if (event.aborted) {
          this.showStatus("Auto-compaction cancelled");
        } else if (event.result) {
          // Rebuild chat to show compacted state
          this.chatContainer.clear();
          this.rebuildChatFromMessages();
          // Add compaction component at bottom so user sees it without scrolling
          this.addMessageToChat({
            role: "compactionSummary",
            tokensBefore: event.result.tokensBefore,
            summary: event.result.summary,
            timestamp: Date.now(),
          });
          this.footer.invalidate();
        } else if (event.errorMessage) {
          // Compaction failed (e.g., quota exceeded, API error)
          this.chatContainer.addChild(new Spacer(1));
          this.chatContainer.addChild(
            new Text(theme.fg("error", event.errorMessage), 1, 0),
          );
        }
        void this.flushCompactionQueue({ willRetry: event.willRetry });
        this.ui.requestRender();
        break;
      }

      case "auto_retry_start": {
        // Set up escape to abort retry
        this.retryEscapeHandler = this.defaultEditor.onEscape;
        this.defaultEditor.onEscape = () => {
          this.session.abortRetry();
        };
        // Show retry indicator
        this.statusContainer.clear();
        const delaySeconds = Math.round(event.delayMs / 1000);
        this.retryLoader = new Loader(
          this.ui,
          (spinner) => theme.fg("warning", spinner),
          (text) => theme.fg("muted", text),
          `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s... (${appKey(this.keybindings, "interrupt")} to cancel)`,
        );
        this.statusContainer.addChild(this.retryLoader);
        this.ui.requestRender();
        break;
      }

      case "auto_retry_end": {
        // Restore escape handler
        if (this.retryEscapeHandler) {
          this.defaultEditor.onEscape = this.retryEscapeHandler;
          this.retryEscapeHandler = undefined;
        }
        // Stop loader
        if (this.retryLoader) {
          this.retryLoader.stop();
          this.retryLoader = undefined;
          this.statusContainer.clear();
        }
        // Show error only on final failure (success shows normal response)
        if (!event.success) {
          this.showError(
            `Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`,
          );
        }
        this.ui.requestRender();
        break;
      }
    }
  }

  /** Extract text content from a user message */
  private getUserMessageText(message: Message): string {
    if (message.role !== "user") return "";
    const textBlocks =
      typeof message.content === "string"
        ? [{ type: "text", text: message.content }]
        : message.content.filter((c: { type: string }) => c.type === "text");
    return textBlocks.map((c) => (c as { text: string }).text).join("");
  }

  /**
   * Show a status message in the chat.
   *
   * If multiple status messages are emitted back-to-back (without anything else being added to the chat),
   * we update the previous status line instead of appending new ones to avoid log spam.
   */
  private showStatus(message: string): void {
    const children = this.chatContainer.children;
    const last =
      children.length > 0 ? children[children.length - 1] : undefined;
    const secondLast =
      children.length > 1 ? children[children.length - 2] : undefined;

    if (
      last &&
      secondLast &&
      last === this.lastStatusText &&
      secondLast === this.lastStatusSpacer
    ) {
      this.lastStatusText.setText(theme.fg("dim", message));
      this.ui.requestRender();
      return;
    }

    const spacer = new Spacer(1);
    const text = new Text(theme.fg("dim", message), 1, 0);
    this.chatContainer.addChild(spacer);
    this.chatContainer.addChild(text);
    this.lastStatusSpacer = spacer;
    this.lastStatusText = text;
    this.ui.requestRender();
  }

  private addMessageToChat(
    message: AgentMessage,
    options?: { populateHistory?: boolean },
  ): void {
    switch (message.role) {
      case "bashExecution": {
        const component = new BashExecutionComponent(
          message.command,
          this.ui,
          message.excludeFromContext,
        );
        if (message.output) {
          component.appendOutput(message.output);
        }
        component.setComplete(
          message.exitCode,
          message.cancelled,
          message.truncated
            ? ({ truncated: true } as TruncationResult)
            : undefined,
          message.fullOutputPath,
        );
        this.chatContainer.addChild(component);
        break;
      }
      case "custom": {
        if (message.display) {
          const renderer = this.session.extensionRunner?.getMessageRenderer(
            message.customType,
          );
          const component = new CustomMessageComponent(
            message,
            renderer,
            this.getMarkdownThemeWithSettings(),
          );
          component.setExpanded(this.toolOutputExpanded);
          this.chatContainer.addChild(component);
        }
        break;
      }
      case "compactionSummary": {
        this.chatContainer.addChild(new Spacer(1));
        const component = new CompactionSummaryMessageComponent(
          message,
          this.getMarkdownThemeWithSettings(),
        );
        component.setExpanded(this.toolOutputExpanded);
        this.chatContainer.addChild(component);
        break;
      }
      case "branchSummary": {
        this.chatContainer.addChild(new Spacer(1));
        const component = new BranchSummaryMessageComponent(
          message,
          this.getMarkdownThemeWithSettings(),
        );
        component.setExpanded(this.toolOutputExpanded);
        this.chatContainer.addChild(component);
        break;
      }
      case "user": {
        const textContent = this.getUserMessageText(message);
        if (textContent) {
          const skillBlock = parseSkillBlock(textContent);
          if (skillBlock) {
            // Render skill block (collapsible)
            this.chatContainer.addChild(new Spacer(1));
            const component = new SkillInvocationMessageComponent(
              skillBlock,
              this.getMarkdownThemeWithSettings(),
            );
            component.setExpanded(this.toolOutputExpanded);
            this.chatContainer.addChild(component);
            // Render user message separately if present
            if (skillBlock.userMessage) {
              const userComponent = new UserMessageComponent(
                skillBlock.userMessage,
                this.getMarkdownThemeWithSettings(),
              );
              this.chatContainer.addChild(userComponent);
            }
          } else {
            const userComponent = new UserMessageComponent(
              textContent,
              this.getMarkdownThemeWithSettings(),
            );
            this.chatContainer.addChild(userComponent);
          }
          if (options?.populateHistory) {
            this.editor.addToHistory?.(textContent);
          }
        }
        break;
      }
      case "assistant": {
        const assistantComponent = new AssistantMessageComponent(
          message,
          this.hideThinkingBlock,
          this.getMarkdownThemeWithSettings(),
        );
        this.chatContainer.addChild(assistantComponent);
        break;
      }
      case "toolResult": {
        // Tool results are rendered inline with tool calls, handled separately
        break;
      }
      default: {
        const _exhaustive: never = message;
      }
    }
  }

  /**
   * Render session context to chat. Used for initial load and rebuild after compaction.
   * @param sessionContext Session context to render
   * @param options.updateFooter Update footer state
   * @param options.populateHistory Add user messages to editor history
   */
  private renderSessionContext(
    sessionContext: SessionContext,
    options: { updateFooter?: boolean; populateHistory?: boolean } = {},
  ): void {
    this.pendingTools.clear();

    if (options.updateFooter) {
      this.footer.invalidate();
      this.updateEditorBorderColor();
    }

    for (const message of sessionContext.messages) {
      // Assistant messages need special handling for tool calls
      if (message.role === "assistant") {
        this.addMessageToChat(message);
        // Render tool call components
        for (const content of message.content) {
          if (content.type === "toolCall") {
            const component = new ToolExecutionComponent(
              content.name,
              content.arguments,
              { showImages: this.settingsManager.getShowImages() },
              this.getRegisteredToolDefinition(content.name),
              this.ui,
            );
            component.setExpanded(this.toolOutputExpanded);
            this.chatContainer.addChild(component);

            if (
              message.stopReason === "aborted" ||
              message.stopReason === "error"
            ) {
              let errorMessage: string;
              if (message.stopReason === "aborted") {
                const retryAttempt = this.session.retryAttempt;
                errorMessage =
                  retryAttempt > 0
                    ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
                    : "Operation aborted";
              } else {
                errorMessage = message.errorMessage || "Error";
              }
              component.updateResult({
                content: [{ type: "text", text: errorMessage }],
                isError: true,
              });
            } else {
              this.pendingTools.set(content.id, component);
            }
          }
        }
      } else if (message.role === "toolResult") {
        // Match tool results to pending tool components
        const component = this.pendingTools.get(message.toolCallId);
        if (component) {
          component.updateResult(message);
          this.pendingTools.delete(message.toolCallId);
        }
      } else {
        // All other messages use standard rendering
        this.addMessageToChat(message, options);
      }
    }

    this.pendingTools.clear();
    this.ui.requestRender();
  }

  renderInitialMessages(): void {
    // Get aligned messages and entries from session context
    const context = this.sessionManager.buildSessionContext();
    this.renderSessionContext(context, {
      updateFooter: true,
      populateHistory: true,
    });

    // Show compaction info if session was compacted
    const allEntries = this.sessionManager.getEntries();
    const compactionCount = allEntries.filter(
      (e) => e.type === "compaction",
    ).length;
    if (compactionCount > 0) {
      const times =
        compactionCount === 1 ? "1 time" : `${compactionCount} times`;
      this.showStatus(`Session compacted ${times}`);
    }
  }

  async getUserInput(): Promise<string> {
    return new Promise((resolve) => {
      this.onInputCallback = (text: string) => {
        this.onInputCallback = undefined;
        resolve(text);
      };
    });
  }

  private rebuildChatFromMessages(): void {
    this.chatContainer.clear();
    const context = this.sessionManager.buildSessionContext();
    this.renderSessionContext(context);
  }

  // =========================================================================
  // Key handlers
  // =========================================================================

  private handleCtrlC(): void {
    const now = Date.now();
    if (now - this.lastSigintTime < 500) {
      void this.shutdown();
    } else {
      this.clearEditor();
      this.lastSigintTime = now;
    }
  }

  private handleCtrlD(): void {
    // Only called when editor is empty (enforced by CustomEditor)
    void this.shutdown();
  }

  /**
   * Gracefully shutdown the agent.
   * Emits shutdown event to extensions, then exits.
   */
  private isShuttingDown = false;

  private async shutdown(): Promise<void> {
    if (this.isShuttingDown) return;
    this.isShuttingDown = true;

    // Emit shutdown event to extensions
    const extensionRunner = this.session.extensionRunner;
    if (extensionRunner?.hasHandlers("session_shutdown")) {
      await extensionRunner.emit({
        type: "session_shutdown",
      });
    }

    // Wait for any pending renders to complete
    // requestRender() uses process.nextTick(), so we wait one tick
    await new Promise((resolve) => process.nextTick(resolve));

    // Drain any in-flight Kitty key release events before stopping.
    // This prevents escape sequences from leaking to the parent shell over slow SSH.
    await this.ui.terminal.drainInput(1000);

    this.stop();
    process.exit(0);
  }

  /**
   * Check if shutdown was requested and perform shutdown if so.
   */
  private async checkShutdownRequested(): Promise<void> {
    if (!this.shutdownRequested) return;
    await this.shutdown();
  }

  private handleCtrlZ(): void {
    // Ignore SIGINT while suspended so Ctrl+C in the terminal does not
    // kill the backgrounded process. The handler is removed on resume.
    const ignoreSigint = () => {};
    process.on("SIGINT", ignoreSigint);

    // Set up handler to restore TUI when resumed
    process.once("SIGCONT", () => {
      process.removeListener("SIGINT", ignoreSigint);
      this.ui.start();
      this.ui.requestRender(true);
    });

    // Stop the TUI (restore terminal to normal mode)
    this.ui.stop();

    // Send SIGTSTP to process group (pid=0 means all processes in group)
    process.kill(0, "SIGTSTP");
  }

  private async handleFollowUp(): Promise<void> {
    const text = (
      this.editor.getExpandedText?.() ?? this.editor.getText()
    ).trim();
    if (!text) return;

    // Queue input during compaction (extension commands execute immediately)
    if (this.session.isCompacting) {
      if (this.isExtensionCommand(text)) {
        this.editor.addToHistory?.(text);
        this.editor.setText("");
        await this.session.prompt(text);
      } else {
        this.queueCompactionMessage(text, "followUp");
      }
      return;
    }

    // Alt+Enter queues a follow-up message (waits until agent finishes)
    // This handles extension commands (execute immediately), prompt template expansion, and queueing
    if (this.session.isStreaming) {
      this.editor.addToHistory?.(text);
      this.editor.setText("");
      await this.session.prompt(text, { streamingBehavior: "followUp" });
      this.updatePendingMessagesDisplay();
      this.ui.requestRender();
    }
    // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit)
    else if (this.editor.onSubmit) {
      this.editor.onSubmit(text);
    }
  }

  private handleDequeue(): void {
    const restored = this.restoreQueuedMessagesToEditor();
    if (restored === 0) {
      this.showStatus("No queued messages to restore");
    } else {
      this.showStatus(
        `Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`,
      );
    }
  }

  private updateEditorBorderColor(): void {
    if (this.isBashMode) {
      this.editor.borderColor = theme.getBashModeBorderColor();
    } else {
      const level = this.session.thinkingLevel || "off";
      this.editor.borderColor = theme.getThinkingBorderColor(level);
    }
    this.ui.requestRender();
  }

  private cycleThinkingLevel(): void {
    const newLevel = this.session.cycleThinkingLevel();
    if (newLevel === undefined) {
      this.showStatus("Current model does not support thinking");
    } else {
      this.footer.invalidate();
      this.updateEditorBorderColor();
      this.showStatus(`Thinking level: ${newLevel}`);
    }
  }

  private async cycleModel(direction: "forward" | "backward"): Promise<void> {
    try {
      const result = await this.session.cycleModel(direction);
      if (result === undefined) {
        const msg =
          this.session.scopedModels.length > 0
            ? "Only one model in scope"
            : "Only one model available";
        this.showStatus(msg);
      } else {
        this.footer.invalidate();
        this.updateEditorBorderColor();
        const thinkingStr =
          result.model.reasoning && result.thinkingLevel !== "off"
            ? ` (thinking: ${result.thinkingLevel})`
            : "";
        this.showStatus(
          `Switched to ${result.model.name || result.model.id}${thinkingStr}`,
        );
      }
    } catch (error) {
      this.showError(error instanceof Error ? error.message : String(error));
    }
  }

  private toggleToolOutputExpansion(): void {
    this.setToolsExpanded(!this.toolOutputExpanded);
  }

  private setToolsExpanded(expanded: boolean): void {
    this.toolOutputExpanded = expanded;
    for (const child of this.chatContainer.children) {
      if (isExpandable(child)) {
        child.setExpanded(expanded);
      }
    }
    this.ui.requestRender();
  }

  private toggleThinkingBlockVisibility(): void {
    this.hideThinkingBlock = !this.hideThinkingBlock;
    this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);

    // Rebuild chat from session messages
    this.chatContainer.clear();
    this.rebuildChatFromMessages();

    // If streaming, re-add the streaming component with updated visibility and re-render
    if (this.streamingComponent && this.streamingMessage) {
      this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock);
      this.streamingComponent.updateContent(this.streamingMessage);
      this.chatContainer.addChild(this.streamingComponent);
    }

    this.showStatus(
      `Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`,
    );
  }

  private openExternalEditor(): void {
    // Determine editor (respect $VISUAL, then $EDITOR)
    const editorCmd = process.env.VISUAL || process.env.EDITOR;
    if (!editorCmd) {
      this.showWarning(
        "No editor configured. Set $VISUAL or $EDITOR environment variable.",
      );
      return;
    }

    const currentText =
      this.editor.getExpandedText?.() ?? this.editor.getText();
    const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);

    try {
      // Write current content to temp file
      fs.writeFileSync(tmpFile, currentText, "utf-8");

      // Stop TUI to release terminal
      this.ui.stop();

      // Split by space to support editor arguments (e.g., "code --wait")
      const [editor, ...editorArgs] = editorCmd.split(" ");

      // Spawn editor synchronously with inherited stdio for interactive editing
      const result = spawnSync(editor, [...editorArgs, tmpFile], {
        stdio: "inherit",
      });

      // On successful exit (status 0), replace editor content
      if (result.status === 0) {
        const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
        this.editor.setText(newContent);
      }
      // On non-zero exit, keep original text (no action needed)
    } finally {
      // Clean up temp file
      try {
        fs.unlinkSync(tmpFile);
      } catch {
        // Ignore cleanup errors
      }

      // Restart TUI
      this.ui.start();
      // Force full re-render since external editor uses alternate screen
      this.ui.requestRender(true);
    }
  }

  // =========================================================================
  // UI helpers
  // =========================================================================

  clearEditor(): void {
    this.editor.setText("");
    this.ui.requestRender();
  }

  showError(errorMessage: string): void {
    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(
      new Text(theme.fg("error", `Error: ${errorMessage}`), 1, 0),
    );
    this.ui.requestRender();
  }

  showWarning(warningMessage: string): void {
    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(
      new Text(theme.fg("warning", `Warning: ${warningMessage}`), 1, 0),
    );
    this.ui.requestRender();
  }

  showNewVersionNotification(newVersion: string): void {
    const action = theme.fg(
      "accent",
      getUpdateInstruction("@mariozechner/pi-coding-agent"),
    );
    const updateInstruction =
      theme.fg("muted", `New version ${newVersion} is available. `) + action;
    const changelogUrl = theme.fg(
      "accent",
      "https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md",
    );
    const changelogLine = theme.fg("muted", "Changelog: ") + changelogUrl;

    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(
      new DynamicBorder((text) => theme.fg("warning", text)),
    );
    this.chatContainer.addChild(
      new Text(
        `${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}\n${changelogLine}`,
        1,
        0,
      ),
    );
    this.chatContainer.addChild(
      new DynamicBorder((text) => theme.fg("warning", text)),
    );
    this.ui.requestRender();
  }

  /**
   * Get all queued messages (read-only).
   * Combines session queue and compaction queue.
   */
  private getAllQueuedMessages(): { steering: string[]; followUp: string[] } {
    return {
      steering: [
        ...this.session.getSteeringMessages(),
        ...this.compactionQueuedMessages
          .filter((msg) => msg.mode === "steer")
          .map((msg) => msg.text),
      ],
      followUp: [
        ...this.session.getFollowUpMessages(),
        ...this.compactionQueuedMessages
          .filter((msg) => msg.mode === "followUp")
          .map((msg) => msg.text),
      ],
    };
  }

  /**
   * Clear all queued messages and return their contents.
   * Clears both session queue and compaction queue.
   */
  private clearAllQueues(): { steering: string[]; followUp: string[] } {
    const { steering, followUp } = this.session.clearQueue();
    const compactionSteering = this.compactionQueuedMessages
      .filter((msg) => msg.mode === "steer")
      .map((msg) => msg.text);
    const compactionFollowUp = this.compactionQueuedMessages
      .filter((msg) => msg.mode === "followUp")
      .map((msg) => msg.text);
    this.compactionQueuedMessages = [];
    return {
      steering: [...steering, ...compactionSteering],
      followUp: [...followUp, ...compactionFollowUp],
    };
  }

  private updatePendingMessagesDisplay(): void {
    this.pendingMessagesContainer.clear();
    const { steering: steeringMessages, followUp: followUpMessages } =
      this.getAllQueuedMessages();
    if (steeringMessages.length > 0 || followUpMessages.length > 0) {
      this.pendingMessagesContainer.addChild(new Spacer(1));
      for (const message of steeringMessages) {
        const text = theme.fg("dim", `Steering: ${message}`);
        this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0));
      }
      for (const message of followUpMessages) {
        const text = theme.fg("dim", `Follow-up: ${message}`);
        this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0));
      }
      const dequeueHint = this.getAppKeyDisplay("dequeue");
      const hintText = theme.fg(
        "dim",
        `↳ ${dequeueHint} to edit all queued messages`,
      );
      this.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
    }
  }

  private restoreQueuedMessagesToEditor(options?: {
    abort?: boolean;
    currentText?: string;
  }): number {
    const { steering, followUp } = this.clearAllQueues();
    const allQueued = [...steering, ...followUp];
    if (allQueued.length === 0) {
      this.updatePendingMessagesDisplay();
      if (options?.abort) {
        this.agent.abort();
      }
      return 0;
    }
    const queuedText = allQueued.join("\n\n");
    const currentText = options?.currentText ?? this.editor.getText();
    const combinedText = [queuedText, currentText]
      .filter((t) => t.trim())
      .join("\n\n");
    this.editor.setText(combinedText);
    this.updatePendingMessagesDisplay();
    if (options?.abort) {
      this.agent.abort();
    }
    return allQueued.length;
  }

  private queueCompactionMessage(
    text: string,
    mode: "steer" | "followUp",
  ): void {
    this.compactionQueuedMessages.push({ text, mode });
    this.editor.addToHistory?.(text);
    this.editor.setText("");
    this.updatePendingMessagesDisplay();
    this.showStatus("Queued message for after compaction");
  }

  private isExtensionCommand(text: string): boolean {
    if (!text.startsWith("/")) return false;

    const extensionRunner = this.session.extensionRunner;
    if (!extensionRunner) return false;

    const spaceIndex = text.indexOf(" ");
    const commandName =
      spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
    return !!extensionRunner.getCommand(commandName);
  }

  private async flushCompactionQueue(options?: {
    willRetry?: boolean;
  }): Promise<void> {
    if (this.compactionQueuedMessages.length === 0) {
      return;
    }

    const queuedMessages = [...this.compactionQueuedMessages];
    this.compactionQueuedMessages = [];
    this.updatePendingMessagesDisplay();

    const restoreQueue = (error: unknown) => {
      this.session.clearQueue();
      this.compactionQueuedMessages = queuedMessages;
      this.updatePendingMessagesDisplay();
      this.showError(
        `Failed to send queued message${queuedMessages.length > 1 ? "s" : ""}: ${
          error instanceof Error ? error.message : String(error)
        }`,
      );
    };

    try {
      if (options?.willRetry) {
        // When retry is pending, queue messages for the retry turn
        for (const message of queuedMessages) {
          if (this.isExtensionCommand(message.text)) {
            await this.session.prompt(message.text);
          } else if (message.mode === "followUp") {
            await this.session.followUp(message.text);
          } else {
            await this.session.steer(message.text);
          }
        }
        this.updatePendingMessagesDisplay();
        return;
      }

      // Find first non-extension-command message to use as prompt
      const firstPromptIndex = queuedMessages.findIndex(
        (message) => !this.isExtensionCommand(message.text),
      );
      if (firstPromptIndex === -1) {
        // All extension commands - execute them all
        for (const message of queuedMessages) {
          await this.session.prompt(message.text);
        }
        return;
      }

      // Execute any extension commands before the first prompt
      const preCommands = queuedMessages.slice(0, firstPromptIndex);
      const firstPrompt = queuedMessages[firstPromptIndex];
      const rest = queuedMessages.slice(firstPromptIndex + 1);

      for (const message of preCommands) {
        await this.session.prompt(message.text);
      }

      // Send first prompt (starts streaming)
      const promptPromise = this.session
        .prompt(firstPrompt.text)
        .catch((error) => {
          restoreQueue(error);
        });

      // Queue remaining messages
      for (const message of rest) {
        if (this.isExtensionCommand(message.text)) {
          await this.session.prompt(message.text);
        } else if (message.mode === "followUp") {
          await this.session.followUp(message.text);
        } else {
          await this.session.steer(message.text);
        }
      }
      this.updatePendingMessagesDisplay();
      void promptPromise;
    } catch (error) {
      restoreQueue(error);
    }
  }

  /** Move pending bash components from pending area to chat */
  private flushPendingBashComponents(): void {
    for (const component of this.pendingBashComponents) {
      this.pendingMessagesContainer.removeChild(component);
      this.chatContainer.addChild(component);
    }
    this.pendingBashComponents = [];
  }

  // =========================================================================
  // Selectors
  // =========================================================================

  /**
   * Shows a selector component in place of the editor.
   * @param create Factory that receives a `done` callback and returns the component and focus target
   */
  private showSelector(
    create: (done: () => void) => { component: Component; focus: Component },
  ): void {
    const done = () => {
      this.editorContainer.clear();
      this.editorContainer.addChild(this.editor);
      this.ui.setFocus(this.editor);
    };
    const { component, focus } = create(done);
    this.editorContainer.clear();
    this.editorContainer.addChild(component);
    this.ui.setFocus(focus);
    this.ui.requestRender();
  }

  private showSettingsSelector(): void {
    this.showSelector((done) => {
      const selector = new SettingsSelectorComponent(
        {
          autoCompact: this.session.autoCompactionEnabled,
          showImages: this.settingsManager.getShowImages(),
          autoResizeImages: this.settingsManager.getImageAutoResize(),
          blockImages: this.settingsManager.getBlockImages(),
          enableSkillCommands: this.settingsManager.getEnableSkillCommands(),
          steeringMode: this.session.steeringMode,
          followUpMode: this.session.followUpMode,
          transport: this.settingsManager.getTransport(),
          thinkingLevel: this.session.thinkingLevel,
          availableThinkingLevels: this.session.getAvailableThinkingLevels(),
          currentTheme: this.settingsManager.getTheme() || "dark",
          availableThemes: getAvailableThemes(),
          hideThinkingBlock: this.hideThinkingBlock,
          collapseChangelog: this.settingsManager.getCollapseChangelog(),
          doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
          treeFilterMode: this.settingsManager.getTreeFilterMode(),
          showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
          editorPaddingX: this.settingsManager.getEditorPaddingX(),
          autocompleteMaxVisible:
            this.settingsManager.getAutocompleteMaxVisible(),
          quietStartup: this.settingsManager.getQuietStartup(),
          clearOnShrink: this.settingsManager.getClearOnShrink(),
        },
        {
          onAutoCompactChange: (enabled) => {
            this.session.setAutoCompactionEnabled(enabled);
            this.footer.setAutoCompactEnabled(enabled);
          },
          onShowImagesChange: (enabled) => {
            this.settingsManager.setShowImages(enabled);
            for (const child of this.chatContainer.children) {
              if (child instanceof ToolExecutionComponent) {
                child.setShowImages(enabled);
              }
            }
          },
          onAutoResizeImagesChange: (enabled) => {
            this.settingsManager.setImageAutoResize(enabled);
          },
          onBlockImagesChange: (blocked) => {
            this.settingsManager.setBlockImages(blocked);
          },
          onEnableSkillCommandsChange: (enabled) => {
            this.settingsManager.setEnableSkillCommands(enabled);
            this.setupAutocomplete(this.fdPath);
          },
          onSteeringModeChange: (mode) => {
            this.session.setSteeringMode(mode);
          },
          onFollowUpModeChange: (mode) => {
            this.session.setFollowUpMode(mode);
          },
          onTransportChange: (transport) => {
            this.settingsManager.setTransport(transport);
            this.session.agent.setTransport(transport);
          },
          onThinkingLevelChange: (level) => {
            this.session.setThinkingLevel(level);
            this.footer.invalidate();
            this.updateEditorBorderColor();
          },
          onThemeChange: (themeName) => {
            const result = setTheme(themeName, true);
            this.settingsManager.setTheme(themeName);
            this.ui.invalidate();
            if (!result.success) {
              this.showError(
                `Failed to load theme "${themeName}": ${result.error}\nFell back to dark theme.`,
              );
            }
          },
          onThemePreview: (themeName) => {
            const result = setTheme(themeName, true);
            if (result.success) {
              this.ui.invalidate();
              this.ui.requestRender();
            }
          },
          onHideThinkingBlockChange: (hidden) => {
            this.hideThinkingBlock = hidden;
            this.settingsManager.setHideThinkingBlock(hidden);
            for (const child of this.chatContainer.children) {
              if (child instanceof AssistantMessageComponent) {
                child.setHideThinkingBlock(hidden);
              }
            }
            this.chatContainer.clear();
            this.rebuildChatFromMessages();
          },
          onCollapseChangelogChange: (collapsed) => {
            this.settingsManager.setCollapseChangelog(collapsed);
          },
          onQuietStartupChange: (enabled) => {
            this.settingsManager.setQuietStartup(enabled);
          },
          onDoubleEscapeActionChange: (action) => {
            this.settingsManager.setDoubleEscapeAction(action);
          },
          onTreeFilterModeChange: (mode) => {
            this.settingsManager.setTreeFilterMode(mode);
          },
          onShowHardwareCursorChange: (enabled) => {
            this.settingsManager.setShowHardwareCursor(enabled);
            this.ui.setShowHardwareCursor(enabled);
          },
          onEditorPaddingXChange: (padding) => {
            this.settingsManager.setEditorPaddingX(padding);
            this.defaultEditor.setPaddingX(padding);
            if (
              this.editor !== this.defaultEditor &&
              this.editor.setPaddingX !== undefined
            ) {
              this.editor.setPaddingX(padding);
            }
          },
          onAutocompleteMaxVisibleChange: (maxVisible) => {
            this.settingsManager.setAutocompleteMaxVisible(maxVisible);
            this.defaultEditor.setAutocompleteMaxVisible(maxVisible);
            if (
              this.editor !== this.defaultEditor &&
              this.editor.setAutocompleteMaxVisible !== undefined
            ) {
              this.editor.setAutocompleteMaxVisible(maxVisible);
            }
          },
          onClearOnShrinkChange: (enabled) => {
            this.settingsManager.setClearOnShrink(enabled);
            this.ui.setClearOnShrink(enabled);
          },
          onCancel: () => {
            done();
            this.ui.requestRender();
          },
        },
      );
      return { component: selector, focus: selector.getSettingsList() };
    });
  }

  private async handleModelCommand(searchTerm?: string): Promise<void> {
    if (!searchTerm) {
      this.showModelSelector();
      return;
    }

    const model = await this.findExactModelMatch(searchTerm);
    if (model) {
      try {
        await this.session.setModel(model);
        this.footer.invalidate();
        this.updateEditorBorderColor();
        this.showStatus(`Model: ${model.id}`);
        this.checkDaxnutsEasterEgg(model);
      } catch (error) {
        this.showError(error instanceof Error ? error.message : String(error));
      }
      return;
    }

    this.showModelSelector(searchTerm);
  }

  private async findExactModelMatch(
    searchTerm: string,
  ): Promise<Model<any> | undefined> {
    const term = searchTerm.trim();
    if (!term) return undefined;

    let targetProvider: string | undefined;
    let targetModelId = "";

    if (term.includes("/")) {
      const parts = term.split("/", 2);
      targetProvider = parts[0]?.trim().toLowerCase();
      targetModelId = parts[1]?.trim().toLowerCase() ?? "";
    } else {
      targetModelId = term.toLowerCase();
    }

    if (!targetModelId) return undefined;

    const models = await this.getModelCandidates();
    const exactMatches = models.filter((item) => {
      const idMatch = item.id.toLowerCase() === targetModelId;
      const providerMatch =
        !targetProvider || item.provider.toLowerCase() === targetProvider;
      return idMatch && providerMatch;
    });

    return exactMatches.length === 1 ? exactMatches[0] : undefined;
  }

  private async getModelCandidates(): Promise<Model<any>[]> {
    if (this.session.scopedModels.length > 0) {
      return this.session.scopedModels.map((scoped) => scoped.model);
    }

    this.session.modelRegistry.refresh();
    try {
      return await this.session.modelRegistry.getAvailable();
    } catch {
      return [];
    }
  }

  /** Update the footer's available provider count from current model candidates */
  private async updateAvailableProviderCount(): Promise<void> {
    const models = await this.getModelCandidates();
    const uniqueProviders = new Set(models.map((m) => m.provider));
    this.footerDataProvider.setAvailableProviderCount(uniqueProviders.size);
  }

  private showModelSelector(initialSearchInput?: string): void {
    this.showSelector((done) => {
      const selector = new ModelSelectorComponent(
        this.ui,
        this.session.model,
        this.settingsManager,
        this.session.modelRegistry,
        this.session.scopedModels,
        async (model) => {
          try {
            await this.session.setModel(model);
            this.footer.invalidate();
            this.updateEditorBorderColor();
            done();
            this.showStatus(`Model: ${model.id}`);
            this.checkDaxnutsEasterEgg(model);
          } catch (error) {
            done();
            this.showError(
              error instanceof Error ? error.message : String(error),
            );
          }
        },
        () => {
          done();
          this.ui.requestRender();
        },
        initialSearchInput,
      );
      return { component: selector, focus: selector };
    });
  }

  private async showModelsSelector(): Promise<void> {
    // Get all available models
    this.session.modelRegistry.refresh();
    const allModels = this.session.modelRegistry.getAvailable();

    if (allModels.length === 0) {
      this.showStatus("No models available");
      return;
    }

    // Check if session has scoped models (from previous session-only changes or CLI --models)
    const sessionScopedModels = this.session.scopedModels;
    const hasSessionScope = sessionScopedModels.length > 0;

    // Build enabled model IDs from session state or settings
    const enabledModelIds = new Set<string>();
    let hasFilter = false;

    if (hasSessionScope) {
      // Use current session's scoped models
      for (const sm of sessionScopedModels) {
        enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`);
      }
      hasFilter = true;
    } else {
      // Fall back to settings
      const patterns = this.settingsManager.getEnabledModels();
      if (patterns !== undefined && patterns.length > 0) {
        hasFilter = true;
        const scopedModels = await resolveModelScope(
          patterns,
          this.session.modelRegistry,
        );
        for (const sm of scopedModels) {
          enabledModelIds.add(`${sm.model.provider}/${sm.model.id}`);
        }
      }
    }

    // Track current enabled state (session-only until persisted)
    const currentEnabledIds = new Set(enabledModelIds);
    let currentHasFilter = hasFilter;

    // Helper to update session's scoped models (session-only, no persist)
    const updateSessionModels = async (enabledIds: Set<string>) => {
      if (enabledIds.size > 0 && enabledIds.size < allModels.length) {
        const newScopedModels = await resolveModelScope(
          Array.from(enabledIds),
          this.session.modelRegistry,
        );
        this.session.setScopedModels(
          newScopedModels.map((sm) => ({
            model: sm.model,
            thinkingLevel: sm.thinkingLevel,
          })),
        );
      } else {
        // All enabled or none enabled = no filter
        this.session.setScopedModels([]);
      }
      await this.updateAvailableProviderCount();
      this.ui.requestRender();
    };

    this.showSelector((done) => {
      const selector = new ScopedModelsSelectorComponent(
        {
          allModels,
          enabledModelIds: currentEnabledIds,
          hasEnabledModelsFilter: currentHasFilter,
        },
        {
          onModelToggle: async (modelId, enabled) => {
            if (enabled) {
              currentEnabledIds.add(modelId);
            } else {
              currentEnabledIds.delete(modelId);
            }
            currentHasFilter = true;
            await updateSessionModels(currentEnabledIds);
          },
          onEnableAll: async (allModelIds) => {
            currentEnabledIds.clear();
            for (const id of allModelIds) {
              currentEnabledIds.add(id);
            }
            currentHasFilter = false;
            await updateSessionModels(currentEnabledIds);
          },
          onClearAll: async () => {
            currentEnabledIds.clear();
            currentHasFilter = true;
            await updateSessionModels(currentEnabledIds);
          },
          onToggleProvider: async (_provider, modelIds, enabled) => {
            for (const id of modelIds) {
              if (enabled) {
                currentEnabledIds.add(id);
              } else {
                currentEnabledIds.delete(id);
              }
            }
            currentHasFilter = true;
            await updateSessionModels(currentEnabledIds);
          },
          onPersist: (enabledIds) => {
            // Persist to settings
            const newPatterns =
              enabledIds.length === allModels.length
                ? undefined // All enabled = clear filter
                : enabledIds;
            this.settingsManager.setEnabledModels(newPatterns);
            this.showStatus("Model selection saved to settings");
          },
          onCancel: () => {
            done();
            this.ui.requestRender();
          },
        },
      );
      return { component: selector, focus: selector };
    });
  }

  private showUserMessageSelector(): void {
    const userMessages = this.session.getUserMessagesForForking();

    if (userMessages.length === 0) {
      this.showStatus("No messages to fork from");
      return;
    }

    this.showSelector((done) => {
      const selector = new UserMessageSelectorComponent(
        userMessages.map((m) => ({ id: m.entryId, text: m.text })),
        async (entryId) => {
          const result = await this.session.fork(entryId);
          if (result.cancelled) {
            // Extension cancelled the fork
            done();
            this.ui.requestRender();
            return;
          }

          this.chatContainer.clear();
          this.renderInitialMessages();
          this.editor.setText(result.selectedText);
          done();
          this.showStatus("Branched to new session");
        },
        () => {
          done();
          this.ui.requestRender();
        },
      );
      return { component: selector, focus: selector.getMessageList() };
    });
  }

  private showTreeSelector(initialSelectedId?: string): void {
    const tree = this.sessionManager.getTree();
    const realLeafId = this.sessionManager.getLeafId();
    const initialFilterMode = this.settingsManager.getTreeFilterMode();

    if (tree.length === 0) {
      this.showStatus("No entries in session");
      return;
    }

    this.showSelector((done) => {
      const selector = new TreeSelectorComponent(
        tree,
        realLeafId,
        this.ui.terminal.rows,
        async (entryId) => {
          // Selecting the current leaf is a no-op (already there)
          if (entryId === realLeafId) {
            done();
            this.showStatus("Already at this point");
            return;
          }

          // Ask about summarization
          done(); // Close selector first

          // Loop until user makes a complete choice or cancels to tree
          let wantsSummary = false;
          let customInstructions: string | undefined;

          // Check if we should skip the prompt (user preference to always default to no summary)
          if (!this.settingsManager.getBranchSummarySkipPrompt()) {
            while (true) {
              const summaryChoice = await this.showExtensionSelector(
                "Summarize branch?",
                ["No summary", "Summarize", "Summarize with custom prompt"],
              );

              if (summaryChoice === undefined) {
                // User pressed escape - re-show tree selector with same selection
                this.showTreeSelector(entryId);
                return;
              }

              wantsSummary = summaryChoice !== "No summary";

              if (summaryChoice === "Summarize with custom prompt") {
                customInstructions = await this.showExtensionEditor(
                  "Custom summarization instructions",
                );
                if (customInstructions === undefined) {
                  // User cancelled - loop back to summary selector
                  continue;
                }
              }

              // User made a complete choice
              break;
            }
          }

          // Set up escape handler and loader if summarizing
          let summaryLoader: Loader | undefined;
          const originalOnEscape = this.defaultEditor.onEscape;

          if (wantsSummary) {
            this.defaultEditor.onEscape = () => {
              this.session.abortBranchSummary();
            };
            this.chatContainer.addChild(new Spacer(1));
            summaryLoader = new Loader(
              this.ui,
              (spinner) => theme.fg("accent", spinner),
              (text) => theme.fg("muted", text),
              `Summarizing branch... (${appKey(this.keybindings, "interrupt")} to cancel)`,
            );
            this.statusContainer.addChild(summaryLoader);
            this.ui.requestRender();
          }

          try {
            const result = await this.session.navigateTree(entryId, {
              summarize: wantsSummary,
              customInstructions,
            });

            if (result.aborted) {
              // Summarization aborted - re-show tree selector with same selection
              this.showStatus("Branch summarization cancelled");
              this.showTreeSelector(entryId);
              return;
            }
            if (result.cancelled) {
              this.showStatus("Navigation cancelled");
              return;
            }

            // Update UI
            this.chatContainer.clear();
            this.renderInitialMessages();
            if (result.editorText && !this.editor.getText().trim()) {
              this.editor.setText(result.editorText);
            }
            this.showStatus("Navigated to selected point");
          } catch (error) {
            this.showError(
              error instanceof Error ? error.message : String(error),
            );
          } finally {
            if (summaryLoader) {
              summaryLoader.stop();
              this.statusContainer.clear();
            }
            this.defaultEditor.onEscape = originalOnEscape;
          }
        },
        () => {
          done();
          this.ui.requestRender();
        },
        (entryId, label) => {
          this.sessionManager.appendLabelChange(entryId, label);
          this.ui.requestRender();
        },
        initialSelectedId,
        initialFilterMode,
      );
      return { component: selector, focus: selector };
    });
  }

  private showSessionSelector(): void {
    this.showSelector((done) => {
      const selector = new SessionSelectorComponent(
        (onProgress) =>
          SessionManager.list(
            this.sessionManager.getCwd(),
            this.sessionManager.getSessionDir(),
            onProgress,
          ),
        SessionManager.listAll,
        async (sessionPath) => {
          done();
          await this.handleResumeSession(sessionPath);
        },
        () => {
          done();
          this.ui.requestRender();
        },
        () => {
          void this.shutdown();
        },
        () => this.ui.requestRender(),
        {
          renameSession: async (
            sessionFilePath: string,
            nextName: string | undefined,
          ) => {
            const next = (nextName ?? "").trim();
            if (!next) return;
            const mgr = SessionManager.open(sessionFilePath);
            mgr.appendSessionInfo(next);
          },
          showRenameHint: true,
          keybindings: this.keybindings,
        },

        this.sessionManager.getSessionFile(),
      );
      return { component: selector, focus: selector };
    });
  }

  private async handleResumeSession(sessionPath: string): Promise<void> {
    // Stop loading animation
    if (this.loadingAnimation) {
      this.loadingAnimation.stop();
      this.loadingAnimation = undefined;
    }
    this.statusContainer.clear();

    // Clear UI state
    this.pendingMessagesContainer.clear();
    this.compactionQueuedMessages = [];
    this.streamingComponent = undefined;
    this.streamingMessage = undefined;
    this.pendingTools.clear();

    // Switch session via AgentSession (emits extension session events)
    await this.session.switchSession(sessionPath);

    // Clear and re-render the chat
    this.chatContainer.clear();
    this.renderInitialMessages();
    this.showStatus("Resumed session");
  }

  private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
    if (mode === "logout") {
      const providers = this.session.modelRegistry.authStorage.list();
      const loggedInProviders = providers.filter(
        (p) => this.session.modelRegistry.authStorage.get(p)?.type === "oauth",
      );
      if (loggedInProviders.length === 0) {
        this.showStatus("No OAuth providers logged in. Use /login first.");
        return;
      }
    }

    this.showSelector((done) => {
      const selector = new OAuthSelectorComponent(
        mode,
        this.session.modelRegistry.authStorage,
        async (providerId: string) => {
          done();

          if (mode === "login") {
            await this.showLoginDialog(providerId);
          } else {
            // Logout flow
            const providerInfo = this.session.modelRegistry.authStorage
              .getOAuthProviders()
              .find((p) => p.id === providerId);
            const providerName = providerInfo?.name || providerId;

            try {
              this.session.modelRegistry.authStorage.logout(providerId);
              this.session.modelRegistry.refresh();
              await this.updateAvailableProviderCount();
              this.showStatus(`Logged out of ${providerName}`);
            } catch (error: unknown) {
              this.showError(
                `Logout failed: ${error instanceof Error ? error.message : String(error)}`,
              );
            }
          }
        },
        () => {
          done();
          this.ui.requestRender();
        },
      );
      return { component: selector, focus: selector };
    });
  }

  private async showLoginDialog(providerId: string): Promise<void> {
    const providerInfo = this.session.modelRegistry.authStorage
      .getOAuthProviders()
      .find((p) => p.id === providerId);
    const providerName = providerInfo?.name || providerId;

    // Providers that use callback servers (can paste redirect URL)
    const usesCallbackServer = providerInfo?.usesCallbackServer ?? false;

    // Create login dialog component
    const dialog = new LoginDialogComponent(
      this.ui,
      providerId,
      (_success, _message) => {
        // Completion handled below
      },
    );

    // Show dialog in editor container
    this.editorContainer.clear();
    this.editorContainer.addChild(dialog);
    this.ui.setFocus(dialog);
    this.ui.requestRender();

    // Promise for manual code input (racing with callback server)
    let manualCodeResolve: ((code: string) => void) | undefined;
    let manualCodeReject: ((err: Error) => void) | undefined;
    const manualCodePromise = new Promise<string>((resolve, reject) => {
      manualCodeResolve = resolve;
      manualCodeReject = reject;
    });

    // Restore editor helper
    const restoreEditor = () => {
      this.editorContainer.clear();
      this.editorContainer.addChild(this.editor);
      this.ui.setFocus(this.editor);
      this.ui.requestRender();
    };

    try {
      await this.session.modelRegistry.authStorage.login(
        providerId as OAuthProviderId,
        {
          onAuth: (info: { url: string; instructions?: string }) => {
            dialog.showAuth(info.url, info.instructions);

            if (usesCallbackServer) {
              // Show input for manual paste, racing with callback
              dialog
                .showManualInput(
                  "Paste redirect URL below, or complete login in browser:",
                )
                .then((value) => {
                  if (value && manualCodeResolve) {
                    manualCodeResolve(value);
                    manualCodeResolve = undefined;
                  }
                })
                .catch(() => {
                  if (manualCodeReject) {
                    manualCodeReject(new Error("Login cancelled"));
                    manualCodeReject = undefined;
                  }
                });
            } else if (providerId === "github-copilot") {
              // GitHub Copilot polls after onAuth
              dialog.showWaiting("Waiting for browser authentication...");
            }
            // For Anthropic: onPrompt is called immediately after
          },

          onPrompt: async (prompt: {
            message: string;
            placeholder?: string;
          }) => {
            return dialog.showPrompt(prompt.message, prompt.placeholder);
          },

          onProgress: (message: string) => {
            dialog.showProgress(message);
          },

          onManualCodeInput: () => manualCodePromise,

          signal: dialog.signal,
        },
      );

      // Success
      restoreEditor();
      this.session.modelRegistry.refresh();
      await this.updateAvailableProviderCount();
      this.showStatus(
        `Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`,
      );
    } catch (error: unknown) {
      restoreEditor();
      const errorMsg = error instanceof Error ? error.message : String(error);
      if (errorMsg !== "Login cancelled") {
        this.showError(`Failed to login to ${providerName}: ${errorMsg}`);
      }
    }
  }

  // =========================================================================
  // Command handlers
  // =========================================================================

  private async handleReloadCommand(): Promise<void> {
    if (this.session.isStreaming) {
      this.showWarning(
        "Wait for the current response to finish before reloading.",
      );
      return;
    }
    if (this.session.isCompacting) {
      this.showWarning("Wait for compaction to finish before reloading.");
      return;
    }

    this.resetExtensionUI();

    const loader = new BorderedLoader(
      this.ui,
      theme,
      "Reloading extensions, skills, prompts, themes...",
      {
        cancellable: false,
      },
    );
    const previousEditor = this.editor;
    this.editorContainer.clear();
    this.editorContainer.addChild(loader);
    this.ui.setFocus(loader);
    this.ui.requestRender();

    const dismissLoader = (editor: Component) => {
      loader.dispose();
      this.editorContainer.clear();
      this.editorContainer.addChild(editor);
      this.ui.setFocus(editor);
      this.ui.requestRender();
    };

    try {
      await this.session.reload();
      setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
      this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
      const themeName = this.settingsManager.getTheme();
      const themeResult = themeName
        ? setTheme(themeName, true)
        : { success: true };
      if (!themeResult.success) {
        this.showError(
          `Failed to load theme "${themeName}": ${themeResult.error}\nFell back to dark theme.`,
        );
      }
      const editorPaddingX = this.settingsManager.getEditorPaddingX();
      const autocompleteMaxVisible =
        this.settingsManager.getAutocompleteMaxVisible();
      this.defaultEditor.setPaddingX(editorPaddingX);
      this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);
      if (this.editor !== this.defaultEditor) {
        this.editor.setPaddingX?.(editorPaddingX);
        this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
      }
      this.ui.setShowHardwareCursor(
        this.settingsManager.getShowHardwareCursor(),
      );
      this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
      this.setupAutocomplete(this.fdPath);
      const runner = this.session.extensionRunner;
      if (runner) {
        this.setupExtensionShortcuts(runner);
      }
      this.rebuildChatFromMessages();
      dismissLoader(this.editor as Component);
      this.showLoadedResources({
        extensionPaths: runner?.getExtensionPaths() ?? [],
        force: false,
        showDiagnosticsWhenQuiet: true,
      });
      const modelsJsonError = this.session.modelRegistry.getError();
      if (modelsJsonError) {
        this.showError(`models.json error: ${modelsJsonError}`);
      }
      this.showStatus("Reloaded extensions, skills, prompts, themes");
    } catch (error) {
      dismissLoader(previousEditor as Component);
      this.showError(
        `Reload failed: ${error instanceof Error ? error.message : String(error)}`,
      );
    }
  }

  private async handleExportCommand(text: string): Promise<void> {
    const parts = text.split(/\s+/);
    const outputPath = parts.length > 1 ? parts[1] : undefined;

    try {
      const filePath = await this.session.exportToHtml(outputPath);
      this.showStatus(`Session exported to: ${filePath}`);
    } catch (error: unknown) {
      this.showError(
        `Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`,
      );
    }
  }

  private async handleShareCommand(): Promise<void> {
    // Check if gh is available and logged in
    try {
      const authResult = spawnSync("gh", ["auth", "status"], {
        encoding: "utf-8",
      });
      if (authResult.status !== 0) {
        this.showError(
          "GitHub CLI is not logged in. Run 'gh auth login' first.",
        );
        return;
      }
    } catch {
      this.showError(
        "GitHub CLI (gh) is not installed. Install it from https://cli.github.com/",
      );
      return;
    }

    // Export to a temp file
    const tmpFile = path.join(os.tmpdir(), "session.html");
    try {
      await this.session.exportToHtml(tmpFile);
    } catch (error: unknown) {
      this.showError(
        `Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`,
      );
      return;
    }

    // Show cancellable loader, replacing the editor
    const loader = new BorderedLoader(this.ui, theme, "Creating gist...");
    this.editorContainer.clear();
    this.editorContainer.addChild(loader);
    this.ui.setFocus(loader);
    this.ui.requestRender();

    const restoreEditor = () => {
      loader.dispose();
      this.editorContainer.clear();
      this.editorContainer.addChild(this.editor);
      this.ui.setFocus(this.editor);
      try {
        fs.unlinkSync(tmpFile);
      } catch {
        // Ignore cleanup errors
      }
    };

    // Create a secret gist asynchronously
    let proc: ReturnType<typeof spawn> | null = null;

    loader.onAbort = () => {
      proc?.kill();
      restoreEditor();
      this.showStatus("Share cancelled");
    };

    try {
      const result = await new Promise<{
        stdout: string;
        stderr: string;
        code: number | null;
      }>((resolve) => {
        proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]);
        let stdout = "";
        let stderr = "";
        proc.stdout?.on("data", (data) => {
          stdout += data.toString();
        });
        proc.stderr?.on("data", (data) => {
          stderr += data.toString();
        });
        proc.on("close", (code) => resolve({ stdout, stderr, code }));
      });

      if (loader.signal.aborted) return;

      restoreEditor();

      if (result.code !== 0) {
        const errorMsg = result.stderr?.trim() || "Unknown error";
        this.showError(`Failed to create gist: ${errorMsg}`);
        return;
      }

      // Extract gist ID from the URL returned by gh
      // gh returns something like: https://gist.github.com/username/GIST_ID
      const gistUrl = result.stdout?.trim();
      const gistId = gistUrl?.split("/").pop();
      if (!gistId) {
        this.showError("Failed to parse gist ID from gh output");
        return;
      }

      // Create the preview URL
      const previewUrl = getShareViewerUrl(gistId);
      this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`);
    } catch (error: unknown) {
      if (!loader.signal.aborted) {
        restoreEditor();
        this.showError(
          `Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`,
        );
      }
    }
  }

  private handleCopyCommand(): void {
    const text = this.session.getLastAssistantText();
    if (!text) {
      this.showError("No agent messages to copy yet.");
      return;
    }

    try {
      copyToClipboard(text);
      this.showStatus("Copied last agent message to clipboard");
    } catch (error) {
      this.showError(error instanceof Error ? error.message : String(error));
    }
  }

  private handleNameCommand(text: string): void {
    const name = text.replace(/^\/name\s*/, "").trim();
    if (!name) {
      const currentName = this.sessionManager.getSessionName();
      if (currentName) {
        this.chatContainer.addChild(new Spacer(1));
        this.chatContainer.addChild(
          new Text(theme.fg("dim", `Session name: ${currentName}`), 1, 0),
        );
      } else {
        this.showWarning("Usage: /name <name>");
      }
      this.ui.requestRender();
      return;
    }

    this.sessionManager.appendSessionInfo(name);
    this.updateTerminalTitle();
    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(
      new Text(theme.fg("dim", `Session name set: ${name}`), 1, 0),
    );
    this.ui.requestRender();
  }

  private handleSessionCommand(): void {
    const stats = this.session.getSessionStats();
    const sessionName = this.sessionManager.getSessionName();

    let info = `${theme.bold("Session Info")}\n\n`;
    if (sessionName) {
      info += `${theme.fg("dim", "Name:")} ${sessionName}\n`;
    }
    info += `${theme.fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n`;
    info += `${theme.fg("dim", "ID:")} ${stats.sessionId}\n\n`;
    info += `${theme.bold("Messages")}\n`;
    info += `${theme.fg("dim", "User:")} ${stats.userMessages}\n`;
    info += `${theme.fg("dim", "Assistant:")} ${stats.assistantMessages}\n`;
    info += `${theme.fg("dim", "Tool Calls:")} ${stats.toolCalls}\n`;
    info += `${theme.fg("dim", "Tool Results:")} ${stats.toolResults}\n`;
    info += `${theme.fg("dim", "Total:")} ${stats.totalMessages}\n\n`;
    info += `${theme.bold("Tokens")}\n`;
    info += `${theme.fg("dim", "Input:")} ${stats.tokens.input.toLocaleString()}\n`;
    info += `${theme.fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n`;
    if (stats.tokens.cacheRead > 0) {
      info += `${theme.fg("dim", "Cache Read:")} ${stats.tokens.cacheRead.toLocaleString()}\n`;
    }
    if (stats.tokens.cacheWrite > 0) {
      info += `${theme.fg("dim", "Cache Write:")} ${stats.tokens.cacheWrite.toLocaleString()}\n`;
    }
    info += `${theme.fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;

    if (stats.cost > 0) {
      info += `\n${theme.bold("Cost")}\n`;
      info += `${theme.fg("dim", "Total:")} ${stats.cost.toFixed(4)}`;
    }

    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(new Text(info, 1, 0));
    this.ui.requestRender();
  }

  private handleChangelogCommand(): void {
    const changelogPath = getChangelogPath();
    const allEntries = parseChangelog(changelogPath);

    const changelogMarkdown =
      allEntries.length > 0
        ? allEntries
            .reverse()
            .map((e) => e.content)
            .join("\n\n")
        : "No changelog entries found.";

    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(new DynamicBorder());
    this.chatContainer.addChild(
      new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0),
    );
    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(
      new Markdown(
        changelogMarkdown,
        1,
        1,
        this.getMarkdownThemeWithSettings(),
      ),
    );
    this.chatContainer.addChild(new DynamicBorder());
    this.ui.requestRender();
  }

  /**
   * Capitalize keybinding for display (e.g., "ctrl+c" -> "Ctrl+C").
   */
  private capitalizeKey(key: string): string {
    return key
      .split("/")
      .map((k) =>
        k
          .split("+")
          .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
          .join("+"),
      )
      .join("/");
  }

  /**
   * Get capitalized display string for an app keybinding action.
   */
  private getAppKeyDisplay(action: AppAction): string {
    return this.capitalizeKey(appKey(this.keybindings, action));
  }

  /**
   * Get capitalized display string for an editor keybinding action.
   */
  private getEditorKeyDisplay(action: EditorAction): string {
    return this.capitalizeKey(editorKey(action));
  }

  private handleHotkeysCommand(): void {
    // Navigation keybindings
    const cursorWordLeft = this.getEditorKeyDisplay("cursorWordLeft");
    const cursorWordRight = this.getEditorKeyDisplay("cursorWordRight");
    const cursorLineStart = this.getEditorKeyDisplay("cursorLineStart");
    const cursorLineEnd = this.getEditorKeyDisplay("cursorLineEnd");
    const jumpForward = this.getEditorKeyDisplay("jumpForward");
    const jumpBackward = this.getEditorKeyDisplay("jumpBackward");
    const pageUp = this.getEditorKeyDisplay("pageUp");
    const pageDown = this.getEditorKeyDisplay("pageDown");

    // Editing keybindings
    const submit = this.getEditorKeyDisplay("submit");
    const newLine = this.getEditorKeyDisplay("newLine");
    const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward");
    const deleteWordForward = this.getEditorKeyDisplay("deleteWordForward");
    const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart");
    const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd");
    const yank = this.getEditorKeyDisplay("yank");
    const yankPop = this.getEditorKeyDisplay("yankPop");
    const undo = this.getEditorKeyDisplay("undo");
    const tab = this.getEditorKeyDisplay("tab");

    // App keybindings
    const interrupt = this.getAppKeyDisplay("interrupt");
    const clear = this.getAppKeyDisplay("clear");
    const exit = this.getAppKeyDisplay("exit");
    const suspend = this.getAppKeyDisplay("suspend");
    const cycleThinkingLevel = this.getAppKeyDisplay("cycleThinkingLevel");
    const cycleModelForward = this.getAppKeyDisplay("cycleModelForward");
    const selectModel = this.getAppKeyDisplay("selectModel");
    const expandTools = this.getAppKeyDisplay("expandTools");
    const toggleThinking = this.getAppKeyDisplay("toggleThinking");
    const externalEditor = this.getAppKeyDisplay("externalEditor");
    const followUp = this.getAppKeyDisplay("followUp");
    const dequeue = this.getAppKeyDisplay("dequeue");

    let hotkeys = `
**Navigation**
| Key | Action |
|-----|--------|
| \`Arrow keys\` | Move cursor / browse history (Up when empty) |
| \`${cursorWordLeft}\` / \`${cursorWordRight}\` | Move by word |
| \`${cursorLineStart}\` | Start of line |
| \`${cursorLineEnd}\` | End of line |
| \`${jumpForward}\` | Jump forward to character |
| \`${jumpBackward}\` | Jump backward to character |
| \`${pageUp}\` / \`${pageDown}\` | Scroll by page |

**Editing**
| Key | Action |
|-----|--------|
| \`${submit}\` | Send message |
| \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} |
| \`${deleteWordBackward}\` | Delete word backwards |
| \`${deleteWordForward}\` | Delete word forwards |
| \`${deleteToLineStart}\` | Delete to start of line |
| \`${deleteToLineEnd}\` | Delete to end of line |
| \`${yank}\` | Paste the most-recently-deleted text |
| \`${yankPop}\` | Cycle through the deleted text after pasting |
| \`${undo}\` | Undo |

**Other**
| Key | Action |
|-----|--------|
| \`${tab}\` | Path completion / accept autocomplete |
| \`${interrupt}\` | Cancel autocomplete / abort streaming |
| \`${clear}\` | Clear editor (first) / exit (second) |
| \`${exit}\` | Exit (when editor is empty) |
| \`${suspend}\` | Suspend to background |
| \`${cycleThinkingLevel}\` | Cycle thinking level |
| \`${cycleModelForward}\` | Cycle models |
| \`${selectModel}\` | Open model selector |
| \`${expandTools}\` | Toggle tool output expansion |
| \`${toggleThinking}\` | Toggle thinking block visibility |
| \`${externalEditor}\` | Edit message in external editor |
| \`${followUp}\` | Queue follow-up message |
| \`${dequeue}\` | Restore queued messages |
| \`Ctrl+V\` | Paste image from clipboard |
| \`/\` | Slash commands |
| \`!\` | Run bash command |
| \`!!\` | Run bash command (excluded from context) |
`;

    // Add extension-registered shortcuts
    const extensionRunner = this.session.extensionRunner;
    if (extensionRunner) {
      const shortcuts = extensionRunner.getShortcuts(
        this.keybindings.getEffectiveConfig(),
      );
      if (shortcuts.size > 0) {
        hotkeys += `
**Extensions**
| Key | Action |
|-----|--------|
`;
        for (const [key, shortcut] of shortcuts) {
          const description = shortcut.description ?? shortcut.extensionPath;
          const keyDisplay = key.replace(/\b\w/g, (c) => c.toUpperCase());
          hotkeys += `| \`${keyDisplay}\` | ${description} |\n`;
        }
      }
    }

    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(new DynamicBorder());
    this.chatContainer.addChild(
      new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0),
    );
    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(
      new Markdown(hotkeys.trim(), 1, 1, this.getMarkdownThemeWithSettings()),
    );
    this.chatContainer.addChild(new DynamicBorder());
    this.ui.requestRender();
  }

  private async handleClearCommand(): Promise<void> {
    // Stop loading animation
    if (this.loadingAnimation) {
      this.loadingAnimation.stop();
      this.loadingAnimation = undefined;
    }
    this.statusContainer.clear();

    // New session via session (emits extension session events)
    await this.session.newSession();

    // Clear UI state
    this.chatContainer.clear();
    this.pendingMessagesContainer.clear();
    this.compactionQueuedMessages = [];
    this.streamingComponent = undefined;
    this.streamingMessage = undefined;
    this.pendingTools.clear();

    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(
      new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1),
    );
    this.ui.requestRender();
  }

  private handleDebugCommand(): void {
    const width = this.ui.terminal.columns;
    const height = this.ui.terminal.rows;
    const allLines = this.ui.render(width);

    const debugLogPath = getDebugLogPath();
    const debugData = [
      `Debug output at ${new Date().toISOString()}`,
      `Terminal: ${width}x${height}`,
      `Total lines: ${allLines.length}`,
      "",
      "=== All rendered lines with visible widths ===",
      ...allLines.map((line, idx) => {
        const vw = visibleWidth(line);
        const escaped = JSON.stringify(line);
        return `[${idx}] (w=${vw}) ${escaped}`;
      }),
      "",
      "=== Agent messages (JSONL) ===",
      ...this.session.messages.map((msg) => JSON.stringify(msg)),
      "",
    ].join("\n");

    fs.mkdirSync(path.dirname(debugLogPath), { recursive: true });
    fs.writeFileSync(debugLogPath, debugData);

    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(
      new Text(
        `${theme.fg("accent", "✓ Debug log written")}\n${theme.fg("muted", debugLogPath)}`,
        1,
        1,
      ),
    );
    this.ui.requestRender();
  }

  private handleArminSaysHi(): void {
    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(new ArminComponent(this.ui));
    this.ui.requestRender();
  }

  private handleDaxnuts(): void {
    this.chatContainer.addChild(new Spacer(1));
    this.chatContainer.addChild(new DaxnutsComponent(this.ui));
    this.ui.requestRender();
  }

  private checkDaxnutsEasterEgg(model: { provider: string; id: string }): void {
    if (
      model.provider === "opencode" &&
      model.id.toLowerCase().includes("kimi-k2.5")
    ) {
      this.handleDaxnuts();
    }
  }

  private async handleBashCommand(
    command: string,
    excludeFromContext = false,
  ): Promise<void> {
    const extensionRunner = this.session.extensionRunner;

    // Emit user_bash event to let extensions intercept
    const eventResult = extensionRunner
      ? await extensionRunner.emitUserBash({
          type: "user_bash",
          command,
          excludeFromContext,
          cwd: process.cwd(),
        })
      : undefined;

    // If extension returned a full result, use it directly
    if (eventResult?.result) {
      const result = eventResult.result;

      // Create UI component for display
      this.bashComponent = new BashExecutionComponent(
        command,
        this.ui,
        excludeFromContext,
      );
      if (this.session.isStreaming) {
        this.pendingMessagesContainer.addChild(this.bashComponent);
        this.pendingBashComponents.push(this.bashComponent);
      } else {
        this.chatContainer.addChild(this.bashComponent);
      }

      // Show output and complete
      if (result.output) {
        this.bashComponent.appendOutput(result.output);
      }
      this.bashComponent.setComplete(
        result.exitCode,
        result.cancelled,
        result.truncated
          ? ({ truncated: true, content: result.output } as TruncationResult)
          : undefined,
        result.fullOutputPath,
      );

      // Record the result in session
      this.session.recordBashResult(command, result, { excludeFromContext });
      this.bashComponent = undefined;
      this.ui.requestRender();
      return;
    }

    // Normal execution path (possibly with custom operations)
    const isDeferred = this.session.isStreaming;
    this.bashComponent = new BashExecutionComponent(
      command,
      this.ui,
      excludeFromContext,
    );

    if (isDeferred) {
      // Show in pending area when agent is streaming
      this.pendingMessagesContainer.addChild(this.bashComponent);
      this.pendingBashComponents.push(this.bashComponent);
    } else {
      // Show in chat immediately when agent is idle
      this.chatContainer.addChild(this.bashComponent);
    }
    this.ui.requestRender();

    try {
      const result = await this.session.executeBash(
        command,
        (chunk) => {
          if (this.bashComponent) {
            this.bashComponent.appendOutput(chunk);
            this.ui.requestRender();
          }
        },
        { excludeFromContext, operations: eventResult?.operations },
      );

      if (this.bashComponent) {
        this.bashComponent.setComplete(
          result.exitCode,
          result.cancelled,
          result.truncated
            ? ({ truncated: true, content: result.output } as TruncationResult)
            : undefined,
          result.fullOutputPath,
        );
      }
    } catch (error) {
      if (this.bashComponent) {
        this.bashComponent.setComplete(undefined, false);
      }
      this.showError(
        `Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`,
      );
    }

    this.bashComponent = undefined;
    this.ui.requestRender();
  }

  private async handleCompactCommand(
    customInstructions?: string,
  ): Promise<void> {
    const entries = this.sessionManager.getEntries();
    const messageCount = entries.filter((e) => e.type === "message").length;

    if (messageCount < 2) {
      this.showWarning("Nothing to compact (no messages yet)");
      return;
    }

    await this.executeCompaction(customInstructions, false);
  }

  private async executeCompaction(
    customInstructions?: string,
    isAuto = false,
  ): Promise<CompactionResult | undefined> {
    // Stop loading animation
    if (this.loadingAnimation) {
      this.loadingAnimation.stop();
      this.loadingAnimation = undefined;
    }
    this.statusContainer.clear();

    // Set up escape handler during compaction
    const originalOnEscape = this.defaultEditor.onEscape;
    this.defaultEditor.onEscape = () => {
      this.session.abortCompaction();
    };

    // Show compacting status
    this.chatContainer.addChild(new Spacer(1));
    const cancelHint = `(${appKey(this.keybindings, "interrupt")} to cancel)`;
    const label = isAuto
      ? `Auto-compacting context... ${cancelHint}`
      : `Compacting context... ${cancelHint}`;
    const compactingLoader = new Loader(
      this.ui,
      (spinner) => theme.fg("accent", spinner),
      (text) => theme.fg("muted", text),
      label,
    );
    this.statusContainer.addChild(compactingLoader);
    this.ui.requestRender();

    let result: CompactionResult | undefined;

    try {
      result = await this.session.compact(customInstructions);

      // Rebuild UI
      this.rebuildChatFromMessages();

      // Add compaction component at bottom so user sees it without scrolling
      const msg = createCompactionSummaryMessage(
        result.summary,
        result.tokensBefore,
        new Date().toISOString(),
      );
      this.addMessageToChat(msg);

      this.footer.invalidate();
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      if (
        message === "Compaction cancelled" ||
        (error instanceof Error && error.name === "AbortError")
      ) {
        this.showError("Compaction cancelled");
      } else {
        this.showError(`Compaction failed: ${message}`);
      }
    } finally {
      compactingLoader.stop();
      this.statusContainer.clear();
      this.defaultEditor.onEscape = originalOnEscape;
    }
    void this.flushCompactionQueue({ willRetry: false });
    return result;
  }

  stop(): void {
    if (this.loadingAnimation) {
      this.loadingAnimation.stop();
      this.loadingAnimation = undefined;
    }
    this.clearExtensionTerminalInputListeners();
    this.footer.dispose();
    this.footerDataProvider.dispose();
    if (this.unsubscribe) {
      this.unsubscribe();
    }
    if (this.isInitialized) {
      this.ui.stop();
      this.isInitialized = false;
    }
  }
}
