//! The TUI application: state, the single `AppEvent` channel, the render loop,
//! and input handling / turn dispatch.

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;

use anyhow::Result;
use crossterm::event::{
    DisableBracketedPaste, EnableBracketedPaste, Event as CtEvent, EventStream, KeyCode, KeyEvent,
    KeyEventKind, KeyModifiers,
};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use futures_util::StreamExt;
use hagent_core::config::{Overrides, ToolKind};
use hagent_core::mcp::{AuthKind, McpServerSettings, TokenStore};
use hagent_core::protocol::event::StreamEvent;
use hagent_core::transcript::{CallStatus, RenderedBlock};
use hagent_core::{
    Client, ConvLogger, Conversation, ConversationSnapshot, FileConfig, ModelInfo, Settings,
    SnapshotMeta,
};
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Widget, Wrap};
use ratatui_textarea::TextArea;
use tokio::sync::mpsc::{self, UnboundedSender};
use tokio::task::JoinHandle;
use tokio::time::{interval, sleep, Duration};
use tokio_util::sync::CancellationToken;

use crate::net;
use crate::render;
use crate::render::transcript::{block_lines, is_append_only_text, RenderOpts};
use crate::slash::{self, SlashMenu};
use crate::term::Terminal;

/// The vendored inline terminal (see [`crate::term`]): committed transcript
/// lines live in the terminal's native scrollback, and the bottom viewport's
/// height tracks the composer so the input box grows as you add lines.
type Term = Terminal;

/// Content rows for the in-flight tail while streaming. Settled lines stream
/// into scrollback continuously, so the tail only shows the uncommitted
/// remainder and doesn't need to be tall.
const STREAMING_ROWS: u16 = 3;
/// Content rows for the inline MCP approval panel.
const APPROVAL_ROWS: u16 = 8;
/// Content rows for the inline bootstrap prompt panel. Sized for the forced
/// re-bootstrap panel (6 body lines + a `… uploading` spinner line) inside the
/// `Borders::ALL` block, so neither the first-time nor the force panel clips.
const BOOTSTRAP_ROWS: u16 = 9;
/// Content rows for the inline MCP-login prompt panel.
const MCP_LOGIN_ROWS: u16 = 8;
/// Content rows for the inline list picker (`/resume`, `/rewind`).
const PICKER_ROWS: u16 = 12;
/// Cap on visible rows in the slash-command popup (the table has fewer entries,
/// so in practice every filtered match shows without scrolling).
pub(crate) const MAX_SLASH_ROWS: usize = 8;
/// How long after the first quit chord (Ctrl-C / Ctrl-D) a second press still
/// exits; after this the prime lapses and the next press just re-arms.
const QUIT_WINDOW: Duration = Duration::from_secs(2);
/// How long the resize must stay quiet before the transcript is reflowed to the
/// new width (see [`App::resize_at`]). Coalesces a drag-resize into one reflow.
const RESIZE_DEBOUNCE: Duration = Duration::from_millis(150);
/// A paste spanning at least this many lines (or this many bytes) is collapsed
/// to a `[Pasted text #N +M lines]` placeholder instead of inserted inline.
const PASTE_COLLAPSE_LINES: usize = 5;
const PASTE_COLLAPSE_BYTES: usize = 800;
/// Most queued messages previewed below the streaming tail (the rest are just
/// counted in the status line).
pub(crate) const MAX_QUEUE_PREVIEW: usize = 3;

/// Everything funnels through one channel into the render loop.
#[derive(Debug)]
pub enum AppEvent {
    /// A terminal input event (key / paste / resize).
    Input(CtEvent),
    /// One parsed protocol event from the in-flight turn.
    Stream(StreamEvent),
    /// A transport/parse error mid-turn.
    StreamError(String),
    /// The turn's stream ended normally.
    StreamDone,
    /// The turn was cancelled by the user.
    StreamCancelled,
    /// The file-editing skill bootstrap finished: `Ok(skill_id)` or `Err(msg)`.
    BootstrapResult(std::result::Result<String, String>),
    /// The `/model` model list arrived: `Ok(models)` or `Err(msg)`.
    ModelsLoaded(std::result::Result<Vec<ModelInfo>, String>),
    /// The per-turn MCP token resolution finished (resolved bearers + servers
    /// still needing an interactive login).
    McpTokensReady(TokensReady),
    /// An MCP OAuth login finished: `Ok(())` or `Err(msg)`.
    McpLoginResult(std::result::Result<(), String>),
    /// `/mcp add` finished writing + resolving the new server: the resolved
    /// settings (to add live this session) or an error.
    McpAdded(std::result::Result<McpServerSettings, String>),
    /// Spinner animation tick (only meaningful while streaming / uploading).
    Tick,
}

/// Result of the background [`hagent_core::mcp::ensure_tokens`] step: the
/// resolved `Authorization` value per server label (so the UI thread can copy
/// them back into its settings) and the labels still needing an interactive
/// login. Sent (not the whole `Settings`) over the [`AppEvent`] channel.
#[derive(Debug, Clone)]
pub struct TokensReady {
    pub resolved: Vec<(String, Option<String>)>,
    pub needs_login: Vec<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Normal,
    Streaming,
    /// One or more MCP calls need the user's approval before the turn resumes.
    AwaitingApproval,
    /// On first contact with a server that has no cached file-editing skill,
    /// the user is offered a one-time bootstrap before normal use.
    AwaitingBootstrap,
    /// An MCP server needs OAuth login before the pending turn can proceed; the
    /// user is offered a browser login (see [`McpLogin`]).
    AwaitingMcpLogin,
    /// A modal list picker is open (`/resume`, `/rewind`); see [`Picker`].
    Picker,
}

/// A deferred external-editor session, performed by [`event_loop`] (which owns
/// the terminal) after the current event is applied.
enum PendingEdit {
    /// Edit the current composer text (Ctrl-G).
    Compose,
    /// Edit `config.toml`, then reload settings (`/settings`).
    Settings(PathBuf),
}

/// Which list a [`Picker`] is choosing from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PickerKind {
    Resume,
    Rewind,
    Model,
    Effort,
    /// Choose where `/mcp add <url>` writes the new server: `.mcp.json` or
    /// `config.toml` (the row's `value` is `"json"` / `"toml"`).
    McpAddDest,
    /// The top-level `/mcp` menu: one row per server (value = label) plus an
    /// "add" row (value = [`MCP_MENU_ADD`]).
    McpMenu,
    /// Per-server action menu (value = `"login"` / `"logout"`); the server is in
    /// [`App::mcp_menu_server`].
    McpServerActions,
}

/// Sentinel `value` for the "add a server" row of the `/mcp` menu.
const MCP_MENU_ADD: &str = "\u{1}add-server";

/// One row in a [`Picker`].
pub(crate) struct PickerItem {
    pub(crate) label: String,
    pub(crate) detail: String,
    /// Conversation id for [`PickerKind::Resume`].
    id: Option<String>,
    /// Rewind-point index for [`PickerKind::Rewind`].
    point: Option<usize>,
    /// The value to apply for [`PickerKind::Model`] / [`PickerKind::Effort`].
    value: Option<String>,
}

/// A navigable, type-to-filter modal list (`/resume`, `/rewind`, `/model`,
/// `/effort`). `visible` holds the indices of `items` matching `filter`;
/// `selected` indexes into `visible`.
pub(crate) struct Picker {
    pub(crate) kind: PickerKind,
    items: Vec<PickerItem>,
    pub(crate) filter: String,
    pub(crate) visible: Vec<usize>,
    pub(crate) selected: usize,
    /// `/model` fetches its list asynchronously; true until it arrives.
    pub(crate) loading: bool,
    /// When set, a non-empty filter offers a freeform `use "<filter>"` row so
    /// the user can choose a value not in the list (e.g. an unlisted model).
    allow_custom: bool,
    /// Set when `/model`'s list fetch failed (or returned nothing): the picker
    /// stays open with this message shown so the user can still type/paste a
    /// model name and pick it via the freeform row.
    pub(crate) error: Option<String>,
}

/// One displayed row of a [`Picker`]: a real list entry or the freeform
/// `use "<filter>"` entry (`/model` only).
pub(crate) enum PickerRow<'a> {
    Custom(&'a str),
    Item(&'a PickerItem),
}

impl Picker {
    fn new(kind: PickerKind, items: Vec<PickerItem>) -> Self {
        let allow_custom = kind == PickerKind::Model;
        let visible = (0..items.len()).collect();
        Self {
            kind,
            items,
            filter: String::new(),
            visible,
            selected: 0,
            loading: false,
            allow_custom,
            error: None,
        }
    }

    /// Replace the items (e.g. when `/model`'s async list arrives) and re-filter.
    fn set_items(&mut self, items: Vec<PickerItem>) {
        self.items = items;
        self.loading = false;
        self.refilter();
    }

    /// Whether the freeform "use what I typed" row is currently offered.
    fn custom_active(&self) -> bool {
        self.allow_custom && !self.filter.is_empty()
    }

    /// Number of displayed rows (the custom row, if any, plus the matches).
    fn row_count(&self) -> usize {
        self.visible.len() + self.custom_active() as usize
    }

    fn refilter(&mut self) {
        let f = self.filter.to_ascii_lowercase();
        self.visible = self
            .items
            .iter()
            .enumerate()
            .filter(|(_, it)| {
                f.is_empty()
                    || it.label.to_ascii_lowercase().contains(&f)
                    || it.detail.to_ascii_lowercase().contains(&f)
            })
            .map(|(i, _)| i)
            .collect();
        // Default to the first real match; fall back to the custom row when
        // nothing matches.
        self.selected = if self.visible.is_empty() {
            0
        } else {
            self.custom_active() as usize
        };
        let n = self.row_count();
        if self.selected >= n {
            self.selected = n.saturating_sub(1);
        }
    }

    fn up(&mut self) {
        let n = self.row_count();
        if n > 0 {
            self.selected = (self.selected + n - 1) % n;
        }
    }
    fn down(&mut self) {
        let n = self.row_count();
        if n > 0 {
            self.selected = (self.selected + 1) % n;
        }
    }
    fn push_char(&mut self, c: char) {
        self.filter.push(c);
        self.refilter();
    }
    /// Append pasted text to the filter (dropping control chars / newlines), so a
    /// model name can be pasted into the picker and chosen via the freeform row.
    fn push_str(&mut self, s: &str) {
        self.filter.extend(s.chars().filter(|c| !c.is_control()));
        self.refilter();
    }
    fn pop_char(&mut self) {
        self.filter.pop();
        self.refilter();
    }

    /// The highlighted row, or `None` when the list is empty.
    fn selected_row(&self) -> Option<PickerRow<'_>> {
        let custom = self.custom_active();
        if custom && self.selected == 0 {
            return Some(PickerRow::Custom(&self.filter));
        }
        let i = self.selected - custom as usize;
        self.visible
            .get(i)
            .map(|&i| PickerRow::Item(&self.items[i]))
    }

    /// The selected real item (not the custom row); used by tests.
    #[cfg(test)]
    fn selected_item(&self) -> Option<&PickerItem> {
        match self.selected_row() {
            Some(PickerRow::Item(it)) => Some(it),
            _ => None,
        }
    }

    /// All displayed rows for the renderer (custom row first, then matches).
    pub(crate) fn rows(&self) -> Vec<PickerRow<'_>> {
        let mut rows = Vec::with_capacity(self.row_count());
        if self.custom_active() {
            rows.push(PickerRow::Custom(&self.filter));
        }
        rows.extend(
            self.visible
                .iter()
                .map(|&i| PickerRow::Item(&self.items[i])),
        );
        rows
    }
}

/// State of the one-time file-editing-skill bootstrap.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Bootstrap {
    /// Not offered (cached / shell disabled / `--no-bootstrap`) or already resolved.
    Inactive,
    /// Awaiting the user's y/n decision (mode is [`Mode::AwaitingBootstrap`]).
    Prompt,
    /// Upload in flight (mode is [`Mode::AwaitingBootstrap`], spinner running).
    Uploading,
}

/// State of an interactive MCP OAuth login (mode [`Mode::AwaitingMcpLogin`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpLogin {
    /// No login in progress.
    Inactive,
    /// Offering to log in to this server label (awaiting y/n).
    Prompt(String),
    /// A browser login is in flight for this label (spinner running).
    Running(String),
}

/// A turn whose dispatch is deferred until MCP tokens are resolved/refreshed
/// (and, if needed, a login completes). `user_text` is `None` for an
/// approval-resume continuation.
#[derive(Debug, Clone)]
struct PendingTurn {
    user_text: Option<String>,
}

/// A large paste held behind a `[Pasted text #N +M lines]` placeholder in the
/// composer (Claude-style), expanded back to its full text on submit / when
/// editing in `$EDITOR`.
struct PasteEntry {
    /// The exact placeholder string inserted into the composer.
    placeholder: String,
    /// The full pasted text it stands in for.
    text: String,
}

pub struct App {
    pub settings: Settings,
    pub conv: Conversation,
    pub input: TextArea<'static>,
    pub mode: Mode,
    pub spinner: usize,
    /// Number of leading transcript blocks already committed to native
    /// scrollback. Blocks `[flushed_blocks..]` are still in-flight (rendered in
    /// the live tail) until they settle.
    pub flushed_blocks: usize,
    /// Within the first not-yet-finished block (an append-only text block being
    /// streamed), how many of its rendered lines are already in scrollback. Lets
    /// us commit completed lines while holding back the partial trailing one.
    pub flushed_lines: usize,
    /// Whether any non-empty content has been committed to scrollback yet —
    /// drives the single blank separator line emitted before each new block.
    pub committed_nonempty: bool,
    /// Show the detailed token/cost breakdown in the status line (Ctrl+T).
    pub show_stats: bool,
    /// When the in-flight turn started, for the live streaming progress readout.
    pub turn_start: Option<Instant>,
    pub should_quit: bool,
    /// Where to persist a bootstrapped skill id (the resolved config path).
    config_path: Option<PathBuf>,
    /// The run's flag-layer overrides, retained so `/settings` can re-resolve
    /// the config file with the same precedence after an edit.
    overrides: Overrides,
    /// The file-editing-skill bootstrap state (see [`Bootstrap`]).
    pub bootstrap: Bootstrap,
    /// Whether the current bootstrap prompt is a forced *re*-bootstrap (a skill is
    /// already cached): the panel then offers delete-existing vs keep. Set by
    /// `/bootstrap` and `--force-bootstrap`.
    pub bootstrap_force: bool,
    /// Whether the in-flight (re-)bootstrap deletes the existing skill first. When
    /// it does and the subsequent upload fails, the cached `skill_id` now points at
    /// a deleted skill, so `finish_bootstrap` clears it (see that fn).
    bootstrap_deleting: bool,
    /// First press of a quit chord (Ctrl-C / Ctrl-D): the char and when, so a
    /// second press of the same chord within [`QUIT_WINDOW`] exits. Cleared by any
    /// other key (see [`handle_key`]) or when the window lapses (on a tick).
    quit_prime: Option<(char, Instant)>,
    /// Large pastes held behind placeholders in the composer (see [`PasteEntry`]).
    pastes: Vec<PasteEntry>,
    /// Monotonic counter for paste placeholder numbering (`#1`, `#2`, …).
    paste_counter: usize,
    /// Messages submitted while a turn was streaming, drained one at a time once
    /// the turn settles back to [`Mode::Normal`] (see [`drain_queue`]).
    queued: Vec<String>,
    /// Set by Ctrl-L; [`event_loop`] clears the screen (composer to top) and
    /// resets it, since clearing needs the terminal it owns.
    pending_clear: bool,
    /// Set by `/clear`; [`event_loop`] re-commits the settled welcome banner to
    /// scrollback after the screen clears (it needs the terminal it owns), so a
    /// fresh conversation opens with the same banner as launch.
    pending_welcome: bool,
    /// When the most recent terminal resize arrived. A resize re-anchors the
    /// composer immediately, but the (destructive) full-transcript reflow is
    /// debounced: a tick fires it [`RESIZE_DEBOUNCE`] after the *last* resize so a
    /// drag reflows once, on release, not per intermediate size (see
    /// [`reflow_transcript`]).
    resize_at: Option<Instant>,
    /// Set when [`resize_at`] has settled; [`event_loop`] runs [`reflow_transcript`]
    /// (it needs the terminal it owns).
    pending_reflow: bool,
    /// The open slash-command autocomplete menu (`Some` while the composer's
    /// first line is a `/` prefix that matches commands).
    pub slash: Option<SlashMenu>,
    /// The open modal list picker (`/resume`, `/rewind`).
    picker: Option<Picker>,
    /// A deferred `$EDITOR` session for [`event_loop`] to run.
    pending_edit: Option<PendingEdit>,
    /// Don't show reasoning blocks (toggled by `/reasoning`).
    pub hide_reasoning: bool,
    /// Don't show tool/shell output bodies (toggled by `/output`).
    pub hide_tool_output: bool,
    /// Submitted prompts for this project directory (oldest→newest), with the
    /// Up/Down browse cursor and the stashed in-progress draft (F3).
    history: Vec<String>,
    history_idx: Option<usize>,
    history_draft: Option<String>,
    /// The project directory, the key under which prompt history is stored.
    cwd: PathBuf,
    client: Client,
    tx: UnboundedSender<AppEvent>,
    cancel: Option<CancellationToken>,
    /// The conversation logger (when logging is enabled): used to write the
    /// resumable `conversation.json` snapshot after each settled turn.
    logger: Option<Arc<ConvLogger>>,
    /// When this conversation was first created (epoch ms), carried into each
    /// snapshot so `created` stays stable across resumes.
    created_ms: u64,
    /// Persistent store for MCP OAuth tokens (keychain + file fallback).
    token_store: TokenStore,
    /// A turn deferred while MCP tokens are resolved/refreshed (and possibly a
    /// login completes); dispatched once [`AppEvent::McpTokensReady`] reports no
    /// further logins are needed.
    pending_turn: Option<PendingTurn>,
    /// Interactive MCP login state (drives the [`Mode::AwaitingMcpLogin`] panel).
    mcp_login: McpLogin,
    /// A pending `/mcp add` (label, url) awaiting the destination-picker choice.
    /// A pending `/mcp add`: `(user-supplied label or None to auto-derive, url)`,
    /// awaiting the destination picker.
    pending_mcp_add: Option<(Option<String>, String)>,
    /// The server chosen in the `/mcp` menu, for its action submenu.
    mcp_menu_server: Option<String>,
}

impl App {
    #[allow(clippy::too_many_arguments)]
    fn new(
        settings: Settings,
        client: Client,
        tx: UnboundedSender<AppEvent>,
        config_path: Option<PathBuf>,
        overrides: Overrides,
        bootstrap_pref: Option<bool>,
        force_bootstrap: bool,
        logger: Option<Arc<ConvLogger>>,
        initial: Option<ConversationSnapshot>,
    ) -> Self {
        // `--force-bootstrap` re-offers the bootstrap even when a skill is cached
        // (so an updated edit.py can be re-uploaded); honour `--no-bootstrap` and
        // the shell-needed precondition. The panel then runs in "force" mode.
        let bootstrap_force = force_bootstrap
            && bootstrap_pref != Some(false)
            && settings.tools.contains(&ToolKind::Shell);
        let bootstrap = if bootstrap_force {
            Bootstrap::Prompt
        } else {
            Self::initial_bootstrap(&settings, bootstrap_pref)
        };
        let created_ms = initial
            .as_ref()
            .map(|s| s.created_ms)
            .unwrap_or_else(hagent_core::convlog::now_ms);
        let conv = match &initial {
            Some(snapshot) => Conversation::restore(snapshot),
            None => Conversation::new(),
        };
        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
        let history = hagent_core::history::load(&cwd);
        Self {
            settings,
            conv,
            input: fresh_input(),
            mode: Mode::Normal,
            spinner: 0,
            flushed_blocks: 0,
            flushed_lines: 0,
            committed_nonempty: false,
            show_stats: false,
            turn_start: None,
            should_quit: false,
            config_path,
            overrides,
            bootstrap,
            bootstrap_force,
            bootstrap_deleting: false,
            quit_prime: None,
            pastes: Vec::new(),
            paste_counter: 0,
            queued: Vec::new(),
            pending_clear: false,
            pending_welcome: false,
            resize_at: None,
            pending_reflow: false,
            slash: None,
            picker: None,
            pending_edit: None,
            hide_reasoning: false,
            hide_tool_output: false,
            history,
            history_idx: None,
            history_draft: None,
            cwd,
            client,
            tx,
            cancel: None,
            logger,
            created_ms,
            token_store: TokenStore::new(),
            pending_turn: None,
            mcp_login: McpLogin::Inactive,
            pending_mcp_add: None,
            mcp_menu_server: None,
        }
    }

    /// Display options derived from the current `/reasoning` / `/output` toggles.
    pub(crate) fn render_opts(&self) -> RenderOpts {
        RenderOpts {
            hide_reasoning: self.hide_reasoning,
            hide_tool_output: self.hide_tool_output,
        }
    }

    /// Rows the slash-command popup occupies above the composer (0 when closed).
    pub(crate) fn slash_popup_height(&self) -> u16 {
        self.slash
            .as_ref()
            .map(|m| (m.items.len().min(MAX_SLASH_ROWS) as u16) + 2)
            .unwrap_or(0)
    }

    /// The open picker, for the renderer.
    pub(crate) fn picker(&self) -> Option<&Picker> {
        self.picker.as_ref()
    }

    /// Capture and persist the resumable `conversation.json` snapshot. No-op when
    /// logging is disabled. Best-effort (see [`ConvLogger::write_snapshot`]).
    fn save_snapshot(&self) {
        let Some(logger) = &self.logger else { return };
        let meta = SnapshotMeta {
            conversation_id: logger.conversation_id(),
            base_url: &self.settings.base_url,
            model: &self.settings.model,
            skill_id: self.settings.skill_id.as_deref(),
            created_ms: self.created_ms,
        };
        logger.write_snapshot(&self.conv.capture_snapshot(meta));
    }

    /// Decide whether to offer the file-editing bootstrap: only when the shell
    /// tool is enabled (skills need a container to run in) and no skill is yet
    /// cached for this server. `bootstrap_pref == Some(false)` (`--no-bootstrap`)
    /// suppresses the offer entirely.
    fn initial_bootstrap(settings: &Settings, bootstrap_pref: Option<bool>) -> Bootstrap {
        let needed = settings.tools.contains(&ToolKind::Shell) && settings.skill_id.is_none();
        match bootstrap_pref {
            Some(false) => Bootstrap::Inactive,
            _ if needed => Bootstrap::Prompt,
            _ => Bootstrap::Inactive,
        }
    }

    pub fn is_streaming(&self) -> bool {
        self.mode == Mode::Streaming
    }

    pub fn awaiting_approval(&self) -> bool {
        self.mode == Mode::AwaitingApproval
    }

    pub fn awaiting_bootstrap(&self) -> bool {
        self.mode == Mode::AwaitingBootstrap
    }

    /// Whether the skill upload is currently in flight (drives the spinner).
    pub(crate) fn bootstrap_uploading(&self) -> bool {
        self.bootstrap == Bootstrap::Uploading
    }

    pub fn awaiting_mcp_login(&self) -> bool {
        self.mode == Mode::AwaitingMcpLogin
    }

    /// Whether a bordered modal overlay (picker / approval / bootstrap / MCP
    /// login) currently occupies the viewport. Such a frame replaces the
    /// composer (or a prior, differently-shaped modal) beneath it, so the
    /// terminal repaints the whole viewport for it rather than diffing — see
    /// [`term::Terminal::request_full_repaint`].
    pub(crate) fn is_modal(&self) -> bool {
        self.mode == Mode::Picker
            || self.awaiting_approval()
            || self.awaiting_bootstrap()
            || self.awaiting_mcp_login()
    }

    /// Whether a browser login is in flight (drives the spinner).
    pub(crate) fn mcp_login_running(&self) -> bool {
        matches!(self.mcp_login, McpLogin::Running(_))
    }

    /// The MCP-login panel state, for the renderer.
    pub(crate) fn mcp_login(&self) -> &McpLogin {
        &self.mcp_login
    }

    /// Rows the bottom viewport should occupy this frame: a one-line status bar
    /// plus a content region. When idle the content is the composer, which grows
    /// with the input's line count; streaming and approval use fixed heights.
    /// There's no cap here — `term::Terminal::draw` clamps the viewport to the
    /// screen height, so a very long input fills the screen and then the text
    /// area scrolls internally.
    fn desired_viewport_height(&self) -> u16 {
        let content = if self.awaiting_bootstrap() {
            BOOTSTRAP_ROWS
        } else if self.awaiting_mcp_login() {
            MCP_LOGIN_ROWS
        } else if self.awaiting_approval() {
            APPROVAL_ROWS
        } else if self.mode == Mode::Picker {
            PICKER_ROWS
        } else if self.is_streaming() {
            // The in-flight tail; plus, when the user is composing/queuing a
            // message, the queue previews and the composer below it.
            if self.composing() {
                let queued = self.queued.len().min(MAX_QUEUE_PREVIEW) as u16;
                let composer = (self.input.lines().len() as u16).max(1) + 2;
                STREAMING_ROWS + queued + composer
            } else {
                STREAMING_ROWS
            }
        } else {
            // Composer text rows inside its top and bottom border, plus the
            // slash-command popup (if open) stacked above it.
            (self.input.lines().len() as u16).max(1) + 2 + self.slash_popup_height()
        };
        content + 1
    }
}

/// Set up the inline terminal, run the event loop, and restore on exit.
///
/// We deliberately do **not** use `ratatui::init()` — that enters the alternate
/// screen, which has no scrollback. Instead we stay on the primary screen with
/// an inline viewport so committed transcript lines are real terminal history.
#[allow(clippy::too_many_arguments)]
pub async fn run(
    settings: Settings,
    client: Client,
    config_path: Option<PathBuf>,
    overrides: Overrides,
    bootstrap_pref: Option<bool>,
    force_bootstrap: bool,
    logger: Option<Arc<ConvLogger>>,
    initial: Option<ConversationSnapshot>,
) -> Result<()> {
    let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
    let resumed_approvals = initial
        .as_ref()
        .is_some_and(|s| s.pending_approvals.iter().any(|a| a.decision.is_none()));
    let mut app = App::new(
        settings,
        client,
        tx.clone(),
        config_path,
        overrides,
        bootstrap_pref,
        force_bootstrap,
        logger,
        initial,
    );
    // Write the opening snapshot so the conversation is discoverable (and
    // `--continue`-able) even before its first turn completes.
    app.save_snapshot();

    // Spinner ticks.
    spawn_ticker(tx.clone());
    // Load the syntax-highlighting assets off the hot path (a background blocking
    // thread) so the first code block doesn't stall the render loop mid-stream.
    tokio::task::spawn_blocking(render::markdown::warm);

    enable_raw_mode()?;
    // Bracketed paste lets the terminal hand us a whole paste as one event, so we
    // can collapse a large one to a placeholder (see `handle_paste`).
    let _ = execute!(std::io::stdout(), EnableBracketedPaste);
    let mut terminal = Terminal::new(app.desired_viewport_height())?;
    // Terminal input → AppEvent::Input (handle kept so the editor flow can stop
    // and respawn the reader, releasing the tty while $EDITOR runs). Spawned
    // **after** `Terminal::new`: its construction probes the cursor position
    // (DSR `ESC[6n` → read the reply from stdin via crossterm's single internal
    // event reader), and a live `EventStream` contends for that same reader —
    // racing it can swallow the reply and hang the probe until the next keypress
    // (no logo, "first run sometimes hangs"). No probe happens after construction.
    let reader = spawn_input_reader(tx.clone());
    // Play the one-time startup shimmer over the `H` mascot, then drop the
    // settled banner into scrollback so it sits above the composer like any
    // other transcript history (idle stays zero-redraw afterwards).
    intro_animate(&mut terminal, &app).await?;
    commit_welcome(&mut terminal, &mut app)?;
    // A resumed conversation's restored transcript commits into scrollback on
    // the first event-loop pass; if it was saved mid-approval, re-enter the
    // approval flow. Otherwise offer the one-time file-editing bootstrap.
    if resumed_approvals {
        app.mode = Mode::AwaitingApproval;
    } else if app.bootstrap == Bootstrap::Prompt {
        app.mode = Mode::AwaitingBootstrap;
    }
    let result = event_loop(&mut terminal, &mut app, &mut rx, reader).await;
    // Restore: leave the final viewport in place, show the cursor, and drop onto
    // a fresh line below it so the shell prompt isn't glued to our status bar.
    let _ = execute!(std::io::stdout(), DisableBracketedPaste);
    let _ = disable_raw_mode();
    let _ = terminal.restore();
    println!();
    print_exit_summary(&app);
    result
}

/// On exit, print the session's token/cost usage and — when logging is on — the
/// exact command to resume this conversation. Skipped for an empty session (no
/// turns sent), so just opening and quitting stays quiet. Runs after the
/// terminal is restored, so plain stdout + a little dim ANSI is fine.
fn print_exit_summary(app: &App) {
    if app.conv.totals.messages == 0 {
        return;
    }
    const DIM: &str = "\x1b[90m";
    const RESET: &str = "\x1b[0m";
    println!(
        "  {DIM}session{RESET}  {}",
        render::exit_summary(&app.conv.totals)
    );
    if let Some(logger) = &app.logger {
        println!(
            "  {DIM}resume {RESET}  {}",
            crate::resume_command(logger.conversation_id())
        );
    }
    println!();
}

async fn event_loop(
    terminal: &mut Term,
    app: &mut App,
    rx: &mut mpsc::UnboundedReceiver<AppEvent>,
    mut reader: JoinHandle<()>,
) -> Result<()> {
    let mut dirty = true;
    loop {
        // Commit any newly-settled transcript blocks into native scrollback
        // before repainting the (small) live viewport.
        if commit_scrollback(terminal, app)? {
            dirty = true;
        }
        if dirty {
            let height = app.desired_viewport_height();
            // Modal overlays sit over differently-shaped content (the composer,
            // or a prior modal of the same height that the cell-diff won't fully
            // clear); repaint the whole viewport so no stale glyphs bleed through.
            if app.is_modal() {
                terminal.request_full_repaint();
            }
            terminal.draw(height, |buf, area| render::draw(buf, area, app))?;
            dirty = false;
        }
        let Some(event) = rx.recv().await else {
            break;
        };
        dirty |= apply_event(app, event);
        // Coalesce any further pending events (e.g. delta bursts) before the
        // next draw so we don't redraw/commit per-token.
        while let Ok(event) = rx.try_recv() {
            dirty |= apply_event(app, event);
        }
        // A Ctrl-G / `/settings` request is performed here, where we own the
        // terminal and can suspend it cleanly around the external editor.
        if let Some(edit) = app.pending_edit.take() {
            if let Err(e) = run_external_editor(terminal, app, &mut reader, edit).await {
                tracing::warn!(error = %e, "external editor session failed");
                push_notice(app, format!("editor failed: {e}"));
            }
            dirty = true;
        }
        // Ctrl-L clears the visible screen and re-anchors the composer at the top
        // (done here since it needs the terminal the event loop owns).
        if app.pending_clear {
            app.pending_clear = false;
            let _ = terminal.clear_screen();
            dirty = true;
        }
        // `/clear` re-commits the welcome banner to the freshly-cleared screen so
        // a new conversation opens looking like launch (runs after the clear
        // above, and before the next loop's `commit_scrollback` flushes the new
        // conversation's first notice).
        if app.pending_welcome {
            app.pending_welcome = false;
            let _ = commit_welcome(terminal, app);
            dirty = true;
        }
        // A settled resize: purge scrollback and reprint the transcript reflowed
        // to the new width (needs the terminal the event loop owns).
        if app.pending_reflow {
            app.pending_reflow = false;
            if let Err(e) = reflow_transcript(terminal, app) {
                tracing::warn!(error = %e, "transcript reflow failed");
            }
            dirty = true;
        }
        if app.should_quit {
            break;
        }
    }
    reader.abort();
    // Flush whatever settled on the final turn before we exit.
    let _ = commit_scrollback(terminal, app);
    Ok(())
}

/// Run `$EDITOR` over the composer text (Ctrl-G) or the config file
/// (`/settings`), then fold the result back in. The input reader is aborted
/// first (so its `EventStream` releases the tty) and respawned after; the inline
/// terminal is torn down around the editor and rebuilt on return.
async fn run_external_editor(
    terminal: &mut Term,
    app: &mut App,
    reader: &mut JoinHandle<()>,
    edit: PendingEdit,
) -> Result<()> {
    // Stop the input reader and wait for it to actually terminate (dropping its
    // EventStream) so the editor gets exclusive use of stdin.
    reader.abort();
    let dead = std::mem::replace(reader, tokio::spawn(async {}));
    let _ = dead.await;

    // Resolve the file to edit and seed it.
    let (path, is_temp) = match &edit {
        PendingEdit::Compose => {
            let p = std::env::temp_dir().join(format!("hagent-compose-{}.md", std::process::id()));
            // Expand any pasted-text placeholders so the editor shows the real
            // content (and the placeholders won't survive the round-trip).
            std::fs::write(&p, app.expanded_input())?;
            (p, true)
        }
        PendingEdit::Settings(p) => {
            if !p.exists() {
                if let Some(parent) = p.parent() {
                    let _ = std::fs::create_dir_all(parent);
                }
                let _ = std::fs::write(p, "# hagent config — see config.example.toml\n");
            }
            (p.clone(), false)
        }
    };

    // Leave raw mode and run the editor synchronously on a blocking thread (it
    // inherits the real tty; the reactor keeps running so a turn can stream).
    let _ = disable_raw_mode();
    let editor = std::env::var("VISUAL")
        .or_else(|_| std::env::var("EDITOR"))
        .unwrap_or_else(|_| "vi".to_string());
    let run_path = path.clone();
    let status = tokio::task::spawn_blocking(move || {
        let mut parts = editor.split_whitespace();
        let prog = parts.next().unwrap_or("vi").to_string();
        let rest: Vec<String> = parts.map(str::to_string).collect();
        std::process::Command::new(prog)
            .args(rest)
            .arg(&run_path)
            .status()
    })
    .await;

    // Re-enter raw mode and reclaim the terminal in place (a well-behaved editor
    // restored the screen on exit, so we keep our viewport anchor rather than
    // re-anchoring — recreating it would scroll the old composer up into
    // scrollback as a stale duplicate). Wipe residue, then restart the reader.
    enable_raw_mode()?;
    // The editor may have toggled bracketed paste off; re-enable it.
    let _ = execute!(std::io::stdout(), EnableBracketedPaste);
    let _ = terminal.reclaim();
    *reader = spawn_input_reader(app.tx.clone());

    let ok = matches!(&status, Ok(Ok(s)) if s.success());
    match edit {
        PendingEdit::Compose => {
            if ok {
                if let Ok(text) = std::fs::read_to_string(&path) {
                    // Drop the trailing newline the editor likely added.
                    let text = text.strip_suffix('\n').unwrap_or(&text);
                    app.input = input_with_text(text);
                    // The editor expanded any placeholders inline, so drop them.
                    app.pastes.clear();
                    app.refresh_slash();
                }
            }
        }
        PendingEdit::Settings(_) => {
            if ok {
                reload_settings(app, &path);
            }
        }
    }
    if is_temp {
        let _ = std::fs::remove_file(&path);
    }
    Ok(())
}

/// Reload `config.toml` after a `/settings` edit, re-resolving with the run's
/// flag overrides so CLI precedence still holds. Rebuilds the client (and starts
/// a fresh chain) if the server changed.
fn reload_settings(app: &mut App, path: &std::path::Path) {
    let file = match FileConfig::load(path) {
        Ok(f) => f,
        Err(e) => {
            push_notice(app, format!("config error: {e}"));
            return;
        }
    };
    let new = match hagent_core::resolve(file, Overrides::default(), app.overrides.clone()) {
        Ok(s) => s,
        Err(e) => {
            push_notice(app, format!("config error: {e}"));
            return;
        }
    };
    let server_changed =
        new.base_url != app.settings.base_url || new.api_key != app.settings.api_key;
    app.settings = new;
    if server_changed {
        match Client::new(app.settings.base_url.clone(), app.settings.api_key.clone()) {
            Ok(c) => app.client = c.with_logger(app.logger.clone()),
            Err(e) => push_notice(app, format!("client rebuild failed: {e}")),
        }
        app.conv.last_response_id = None;
        app.conv.container_id = None;
    }
    push_notice(app, "settings reloaded");
}

/// Commit every transcript block (and completed text line) that has reached a
/// stable state into the terminal's scrollback via `insert_before`, advancing
/// the flush cursor. Returns whether anything was committed. Append-only text
/// blocks commit line-by-line as they stream (holding back the partial trailing
/// line); tool/status/approval blocks commit whole, only once final.
fn commit_scrollback(terminal: &mut Term, app: &mut App) -> Result<bool> {
    let width = terminal.size()?.width.max(1);
    let mut committed = false;
    while let Some(chunk) = app.next_commit_chunk() {
        emit_chunk(terminal, chunk, width)?;
        committed = true;
    }
    Ok(committed)
}

/// Number of shimmer frames in the startup intro, and the per-frame delay.
const INTRO_FRAMES: usize = 18;
const INTRO_FRAME_MS: u64 = 70;

/// Play the one-time startup shimmer: render the banner with a highlight
/// sweeping across the `H` mascot directly in the (tall) viewport, frame by
/// frame, then hold the settled frame briefly. Nothing is committed here — the
/// settled banner is drawn at the same rows by [`commit_welcome`] right after,
/// so the hand-off into scrollback is seamless (no duplicate, no flash).
///
/// Drawing the same viewport height each frame means only the changed `H` cells
/// are diffed out (per `term::Terminal::draw`), so the shimmer is flicker-free.
async fn intro_animate(terminal: &mut Term, app: &App) -> Result<()> {
    let height = render::welcome_frame(&app.settings, None).len() as u16;
    let render_frame = |terminal: &mut Term, frame: Option<usize>| -> Result<()> {
        let lines = render::welcome_frame(&app.settings, frame);
        terminal.draw(height, |buf, area| {
            Paragraph::new(lines)
                .wrap(Wrap { trim: false })
                .render(area, buf);
        })?;
        Ok(())
    };
    for f in 0..INTRO_FRAMES {
        render_frame(terminal, Some(f))?;
        sleep(Duration::from_millis(INTRO_FRAME_MS)).await;
    }
    // End on the settled `H` so the commit that follows is identical to the
    // last on-screen frame.
    render_frame(terminal, None)?;
    sleep(Duration::from_millis(180)).await;
    Ok(())
}

/// Commit the one-time startup banner (mascot + session info) to scrollback
/// before the event loop runs. Flags scrollback as non-empty so the first real
/// transcript block gets its blank separator above it.
fn commit_welcome(terminal: &mut Term, app: &mut App) -> Result<()> {
    let width = terminal.size()?.width.max(1);
    emit_chunk(terminal, render::welcome_lines(&app.settings), width)?;
    app.committed_nonempty = true;
    Ok(())
}

/// Reflow the whole transcript to the current terminal width after a resize has
/// settled (debounced via [`App::resize_at`]). Committed scrollback can't be
/// re-wrapped in place — once a line has scrolled above the viewport the cursor
/// can't reach it — so the only route to a re-wrapped history is to purge the
/// scrollback (`Terminal::clear_all`) and reprint everything from memory: the
/// welcome banner, then every settled block, re-rendered at the new width. The
/// flush cursor is rewound to 0 so the next [`commit_scrollback`] pass re-emits
/// the transcript; any still-streaming block stays in the live tail as usual.
/// This trades the user's scroll position (we snap to the bottom) for a properly
/// re-wrapped history — the same destructive reprint `clear` does.
fn reflow_transcript(terminal: &mut Term, app: &mut App) -> Result<()> {
    terminal.clear_all()?;
    // Rewind to the top and re-emit, mirroring launch / `/clear`: banner first
    // (which re-sets `committed_nonempty`), then `commit_scrollback` re-emits the
    // settled transcript on the next loop pass.
    app.flushed_blocks = 0;
    app.flushed_lines = 0;
    app.committed_nonempty = false;
    commit_welcome(terminal, app)?;
    Ok(())
}

/// Render one chunk of finalized lines into scrollback, wrapped to `width` so
/// the reserved height matches what the terminal will actually show.
fn emit_chunk(terminal: &mut Term, lines: Vec<Line<'static>>, width: u16) -> Result<()> {
    let para = Paragraph::new(lines).wrap(Wrap { trim: false });
    let height = (para.line_count(width) as u16).max(1);
    terminal.insert_before(height, move |buf| para.render(buf.area, buf))?;
    Ok(())
}

/// Apply one event; returns whether the screen needs to be redrawn. Idle ticks
/// return `false`, so the app does no work and no rendering while waiting.
fn apply_event(app: &mut App, event: AppEvent) -> bool {
    match event {
        AppEvent::Input(CtEvent::Key(key)) if key.kind == KeyEventKind::Press => {
            handle_key(app, key);
            true
        }
        // A bracketed paste: large ones collapse to a placeholder (see
        // [`handle_paste`]); small ones insert inline. Only when the composer is
        // actually shown (Normal, or Streaming where it's revealed for queuing) —
        // a paste into a modal (approval/bootstrap) would silently land in the
        // hidden composer, so we ignore it there. The exception is a freeform
        // picker (`/model`): a pasted model name feeds its filter so it can be
        // chosen via the freeform row (handy when the list fetch failed).
        AppEvent::Input(CtEvent::Paste(text)) => {
            if matches!(app.mode, Mode::Normal | Mode::Streaming) {
                handle_paste(app, text);
                true
            } else if let Some(p) = app.picker.as_mut().filter(|p| p.allow_custom) {
                p.push_str(&text);
                true
            } else {
                false
            }
        }
        // The transcript lives in native scrollback now, so we don't capture the
        // mouse — the terminal handles wheel-scroll and click-drag selection. The
        // immediate redraw re-anchors the composer to the new size; the full
        // transcript reflow is debounced off [`resize_at`] (a tick fires it once
        // the drag settles — see the `Tick` arm and [`reflow_transcript`]).
        AppEvent::Input(CtEvent::Resize(_, _)) => {
            app.resize_at = Some(Instant::now());
            true
        }
        AppEvent::Input(_) => false,
        AppEvent::Stream(se) => {
            // Don't force the view back to the tail: if we're tailing the new
            // content shows automatically, and if the user has scrolled up they
            // stay put.
            app.conv.apply(se);
            true
        }
        AppEvent::StreamError(msg) => {
            app.conv.fail(msg);
            end_turn(app, false);
            true
        }
        AppEvent::StreamDone => {
            // A clean completion drains the next queued message (if any).
            end_turn(app, true);
            true
        }
        AppEvent::StreamCancelled => {
            app.conv.fail("turn cancelled");
            end_turn(app, false);
            true
        }
        AppEvent::BootstrapResult(result) => {
            finish_bootstrap(app, result);
            true
        }
        AppEvent::ModelsLoaded(result) => {
            finish_models_loaded(app, result);
            true
        }
        AppEvent::McpTokensReady(ready) => {
            finish_tokens_ready(app, ready);
            true
        }
        AppEvent::McpLoginResult(result) => {
            finish_mcp_login(app, result);
            true
        }
        AppEvent::McpAdded(result) => {
            finish_mcp_added(app, result);
            true
        }
        AppEvent::Tick => {
            let mut dirty = false;
            // Let a lapsed "press again to exit" prime clear itself (and its
            // status-line hint) even if the user presses nothing more.
            if matches!(app.quit_prime, Some((_, t)) if t.elapsed() >= QUIT_WINDOW) {
                app.quit_prime = None;
                dirty = true;
            }
            if app.is_streaming() || app.bootstrap_uploading() || app.mcp_login_running() {
                app.spinner = app.spinner.wrapping_add(1);
                dirty = true;
            }
            // A resize has settled: reflow the transcript to the new width on the
            // next event-loop pass (which owns the terminal).
            if matches!(app.resize_at, Some(t) if t.elapsed() >= RESIZE_DEBOUNCE) {
                app.resize_at = None;
                app.pending_reflow = true;
                dirty = true;
            }
            dirty
        }
    }
}

/// Begin uploading the bundled file-editing skill to the current server.
fn start_bootstrap(app: &mut App) {
    app.bootstrap = Bootstrap::Uploading;
    app.bootstrap_deleting = false;
    app.spinner = 0;
    net::spawn_bootstrap(app.client.clone(), app.tx.clone());
}

/// Begin a forced re-bootstrap: re-upload a fresh skill, optionally deleting the
/// existing one(s) first (so an updated `edit.py` actually replaces the old).
fn start_rebootstrap(app: &mut App, delete_existing: bool) {
    app.bootstrap = Bootstrap::Uploading;
    app.bootstrap_deleting = delete_existing;
    app.spinner = 0;
    net::spawn_rebootstrap(app.client.clone(), delete_existing, app.tx.clone());
}

/// `/bootstrap`: (re-)offer the file-editing-skill bootstrap. With a skill
/// already cached this is a *forced* re-bootstrap (the panel offers to delete the
/// existing one); otherwise it's the normal first-time offer.
fn trigger_bootstrap(app: &mut App) {
    if !app.settings.tools.contains(&ToolKind::Shell) {
        push_notice(app, "enable the shell tool to use the file-editing skill");
        return;
    }
    if app.is_streaming() {
        push_notice(app, "finish the current turn before re-bootstrapping");
        return;
    }
    app.bootstrap_force = app.settings.skill_id.is_some();
    app.bootstrap = Bootstrap::Prompt;
    app.mode = Mode::AwaitingBootstrap;
}

/// Decline the bootstrap offer and drop into normal input.
fn decline_bootstrap(app: &mut App) {
    app.bootstrap = Bootstrap::Inactive;
    app.bootstrap_force = false;
    app.mode = Mode::Normal;
}

/// Resolve a finished bootstrap: on success activate the skill for this session
/// and persist its id to the config; on failure surface the reason and continue
/// without it. Either way we return to normal input.
fn finish_bootstrap(app: &mut App, result: std::result::Result<String, String>) {
    app.bootstrap = Bootstrap::Inactive;
    app.bootstrap_force = false;
    let was_deleting = std::mem::take(&mut app.bootstrap_deleting);
    app.mode = Mode::Normal;
    match result {
        Ok(skill_id) => {
            // Active immediately for this session…
            app.settings.skill_id = Some(skill_id.clone());
            // …and persisted so we don't re-upload on the next launch.
            match &app.config_path {
                Some(path) => {
                    if let Err(e) = hagent_core::persist_skill_id(
                        path,
                        &app.settings.base_url,
                        app.settings.api_key.as_deref(),
                        &skill_id,
                    ) {
                        tracing::warn!(error = %e, "could not persist skill id");
                        app.conv.transcript.push(RenderedBlock::Error {
                            message: format!(
                                "file-editing skill uploaded but couldn't be saved to config ({e}); \
                                 it's active this session but you'll be asked again next launch"
                            ),
                            code: None,
                        });
                    } else {
                        tracing::info!(skill_id = %skill_id, "bootstrapped file-editing skill");
                    }
                }
                None => tracing::warn!("no config path; skill id not persisted"),
            }
        }
        Err(msg) => {
            // A delete-then-re-upload that failed likely already removed the old
            // skill, so the cached id now points at nothing — clear it so the next
            // turn doesn't send a dead `skill_reference` (which the server 400s).
            let extra = if was_deleting && app.settings.skill_id.is_some() {
                app.settings.skill_id = None;
                " (the previous skill was removed; re-run /bootstrap to restore it)"
            } else {
                ""
            };
            app.conv.transcript.push(RenderedBlock::Error {
                message: format!(
                    "couldn't bootstrap the file-editing skill: {msg} — continuing without it{extra}"
                ),
                code: None,
            });
        }
    }
}

fn end_turn(app: &mut App, drain: bool) {
    app.cancel = None;
    app.turn_start = None;
    // If the turn parked MCP calls for approval, switch to the decision flow;
    // otherwise we're idle and ready for input.
    app.mode = if app.conv.has_undecided_approvals() {
        Mode::AwaitingApproval
    } else {
        Mode::Normal
    };
    // An MCP tool that failed with an auth-shaped error this turn likely has a
    // bad token (e.g. revoked server-side) — clear it so the next turn re-prompts
    // a login (reactive re-auth, complementing proactive expiry refresh).
    handle_mcp_auth_failures(app);
    // Persist the resumable snapshot now that the turn has settled (chain
    // pointer, transcript and totals are all up to date).
    app.save_snapshot();
    if drain {
        // A clean completion that leaves us idle dispatches the next queued
        // message (typed while streaming); if it parked approvals, the queue
        // waits until those resolve and a later turn settles to Normal.
        if app.mode == Mode::Normal {
            drain_queue(app);
        }
    } else {
        // The turn errored or was cancelled — discard anything queued behind it
        // rather than replaying it later out of order (matches `cancel_turn`).
        app.queued.clear();
    }
    // Leave the scroll position alone; tailing already keeps the bottom in view.
}

/// Clear the stored token for any OAuth MCP server whose tools returned an
/// auth-shaped error this turn, so the next turn re-prompts a login. Static-auth
/// servers are left alone (their credentials are the user's to fix). No-op when
/// nothing failed.
fn handle_mcp_auth_failures(app: &mut App) {
    let failed = app.conv.take_mcp_auth_failures();
    for label in failed {
        let Some(idx) = app
            .settings
            .mcp_servers
            .iter()
            .position(|s| s.server_label == label && s.needs_oauth())
        else {
            continue;
        };
        let url = app.settings.mcp_servers[idx].server_url.clone();
        app.settings.mcp_servers[idx].resolved_authorization = None;
        let _ = app.token_store.delete(&url);
        push_notice(
            app,
            format!(
                "MCP server '{label}' authentication failed — cleared its token; \
                 you'll be asked to log in again on the next message"
            ),
        );
    }
}

/// Dispatch the oldest queued message as the next turn, if we're idle and the
/// queue is non-empty. Each message rides the freshly-advanced chain pointer,
/// so queued turns continue the conversation in order.
fn drain_queue(app: &mut App) {
    if app.mode != Mode::Normal || app.is_streaming() || app.queued.is_empty() {
        return;
    }
    // History was already recorded when the message was queued in `submit`, so
    // don't re-record it here (that would duplicate the recall entry / log line).
    let text = app.queued.remove(0);
    app.conv.push_user(text.clone());
    begin_turn(app, Some(text));
}

/// Start a turn, resolving MCP OAuth tokens first when any server needs them.
///
/// With no OAuth servers this dispatches immediately (the common case — no extra
/// latency). Otherwise it parks the turn, refreshes/resolves tokens off-thread
/// ([`net::spawn_ensure_mcp_tokens`]), and dispatches on
/// [`AppEvent::McpTokensReady`] — or offers a login first. Used by `submit`,
/// `drain_queue`, and the approval-resume path so the bearer is (re)sent every
/// turn (Hadrian never persists it). `user_text` is `None` for an
/// approval-resume continuation.
fn begin_turn(app: &mut App, user_text: Option<String>) {
    app.spinner = 0;
    app.turn_start = Some(Instant::now());
    app.mode = Mode::Streaming;
    if app.settings.mcp_servers.iter().any(|s| s.needs_oauth()) {
        app.pending_turn = Some(PendingTurn { user_text });
        net::spawn_ensure_mcp_tokens(
            app.settings.mcp_servers.clone(),
            app.token_store.clone(),
            app.tx.clone(),
        );
    } else {
        dispatch_turn(app, user_text);
    }
}

/// Build and spawn the turn now (tokens already resolved, or none needed).
fn dispatch_turn(app: &mut App, user_text: Option<String>) {
    let request = app.conv.build_request(&app.settings, user_text);
    app.mode = Mode::Streaming;
    app.cancel = Some(net::spawn_turn(app.client.clone(), request, app.tx.clone()));
}

/// Fold resolved MCP bearers back into settings, then dispatch the parked turn —
/// or, if a server still needs an interactive login, offer it first.
fn finish_tokens_ready(app: &mut App, ready: TokensReady) {
    // The turn may have been cancelled while tokens resolved.
    let Some(pending) = app.pending_turn.take() else {
        return;
    };
    for (label, bearer) in ready.resolved {
        if let Some(server) = app
            .settings
            .mcp_servers
            .iter_mut()
            .find(|s| s.server_label == label)
        {
            server.resolved_authorization = bearer;
        }
    }
    match ready.needs_login.into_iter().next() {
        Some(label) => {
            app.pending_turn = Some(pending); // keep the turn parked
            offer_mcp_login(app, label);
        }
        None => dispatch_turn(app, pending.user_text),
    }
}

/// Enter the auto-login panel for `label` (the pending turn stays parked).
fn offer_mcp_login(app: &mut App, label: String) {
    app.turn_start = None;
    app.mcp_login = McpLogin::Prompt(label);
    app.mode = Mode::AwaitingMcpLogin;
}

/// Begin the browser login for the currently-offered server.
fn start_mcp_login(app: &mut App) {
    let McpLogin::Prompt(label) = &app.mcp_login else {
        return;
    };
    let label = label.clone();
    let Some(server) = app
        .settings
        .mcp_servers
        .iter()
        .find(|s| s.server_label == label)
        .cloned()
    else {
        finish_mcp_login(
            app,
            Err(format!("server '{label}' is no longer configured")),
        );
        return;
    };
    app.mcp_login = McpLogin::Running(label);
    app.spinner = 0;
    net::spawn_mcp_login(server, app.token_store.clone(), app.tx.clone());
}

/// Skip the offered login: abandon the parked turn and return to input.
fn decline_mcp_login(app: &mut App) {
    let label = mcp_login_label(&app.mcp_login);
    app.mcp_login = McpLogin::Inactive;
    app.pending_turn = None;
    app.turn_start = None;
    app.mode = Mode::Normal;
    app.queued.clear();
    if !label.is_empty() {
        push_notice(
            app,
            format!("skipped MCP login for '{label}' — turn cancelled"),
        );
    }
}

/// Resolve a finished MCP login: on success re-resolve tokens (which dispatches
/// the parked turn or offers the next login); on failure surface it and abandon
/// the turn.
fn finish_mcp_login(app: &mut App, result: std::result::Result<(), String>) {
    let label = mcp_login_label(&app.mcp_login);
    app.mcp_login = McpLogin::Inactive;
    match result {
        Ok(()) => {
            if !label.is_empty() {
                push_notice(app, format!("authenticated MCP server '{label}'"));
            }
            if app.pending_turn.is_some() {
                // Re-resolve: fills the new bearer and surfaces any further
                // server needing login, then dispatches the parked turn.
                app.spinner = 0;
                app.turn_start = Some(Instant::now());
                app.mode = Mode::Streaming;
                net::spawn_ensure_mcp_tokens(
                    app.settings.mcp_servers.clone(),
                    app.token_store.clone(),
                    app.tx.clone(),
                );
            } else {
                app.mode = Mode::Normal;
            }
        }
        Err(e) => {
            app.pending_turn = None;
            app.turn_start = None;
            app.mode = Mode::Normal;
            app.queued.clear();
            let scope = if label.is_empty() {
                String::new()
            } else {
                format!(" for '{label}'")
            };
            app.conv.transcript.push(RenderedBlock::Error {
                message: format!("MCP login failed{scope}: {e}"),
                code: None,
            });
        }
    }
}

/// The server label from an [`McpLogin`] state (empty when inactive).
fn mcp_login_label(state: &McpLogin) -> String {
    match state {
        McpLogin::Prompt(l) | McpLogin::Running(l) => l.clone(),
        McpLogin::Inactive => String::new(),
    }
}

fn handle_key(app: &mut App, key: KeyEvent) {
    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
    let alt = key.modifiers.contains(KeyModifiers::ALT);
    let shift = key.modifiers.contains(KeyModifiers::SHIFT);

    // Any keystroke clears a pending "press again to exit" prime; a quit chord
    // re-arms it via `try_quit`. Snapshot it first so this press sees the prior.
    let primed = app.quit_prime.take();

    // The bootstrap prompt captures input until the user decides (or the upload
    // it kicked off finishes, which transitions out of this mode).
    if app.awaiting_bootstrap() {
        // Only the quit chord is live while an upload is in flight.
        if app.bootstrap_uploading() {
            if ctrl && key.code == KeyCode::Char('c') {
                try_quit(app, primed, 'c');
            }
            return;
        }
        match key.code {
            KeyCode::Char('c') if ctrl => try_quit(app, primed, 'c'),
            // Forced re-bootstrap panel: y/Enter delete the existing skill and
            // re-upload; k keeps it (just re-ensure / re-activate).
            KeyCode::Char('y') | KeyCode::Char('a') | KeyCode::Enter if app.bootstrap_force => {
                start_rebootstrap(app, true)
            }
            KeyCode::Char('k') if app.bootstrap_force => start_rebootstrap(app, false),
            // First-time panel: y/a/Enter enable, n/d/Esc skip.
            KeyCode::Char('y') | KeyCode::Char('a') | KeyCode::Enter => start_bootstrap(app),
            KeyCode::Char('n') | KeyCode::Char('d') | KeyCode::Esc => decline_bootstrap(app),
            _ => {}
        }
        return;
    }

    // The MCP-login panel captures input until the user logs in or skips.
    if app.awaiting_mcp_login() {
        // Only the quit chord is live while a browser login is in flight.
        if app.mcp_login_running() {
            if ctrl && key.code == KeyCode::Char('c') {
                try_quit(app, primed, 'c');
            }
            return;
        }
        match key.code {
            KeyCode::Char('c') if ctrl => try_quit(app, primed, 'c'),
            KeyCode::Char('y') | KeyCode::Char('a') | KeyCode::Enter => start_mcp_login(app),
            KeyCode::Char('n') | KeyCode::Char('d') | KeyCode::Esc => decline_mcp_login(app),
            _ => {}
        }
        return;
    }

    // The approval modal captures input until every pending call is decided.
    if app.awaiting_approval() {
        match key.code {
            KeyCode::Char('c') if ctrl => try_quit(app, primed, 'c'),
            KeyCode::Char('a') | KeyCode::Char('y') => decide_approval(app, true),
            KeyCode::Char('d') | KeyCode::Char('n') => decide_approval(app, false),
            _ => {}
        }
        return;
    }

    // The list picker (/resume, /rewind) captures navigation until a choice or
    // cancel.
    if let Some(p) = &mut app.picker {
        match key.code {
            KeyCode::Char('c') if ctrl => try_quit(app, primed, 'c'),
            KeyCode::Up => p.up(),
            KeyCode::Down => p.down(),
            // Type to filter the list; Backspace narrows back.
            KeyCode::Char(c) if !ctrl && !alt => p.push_char(c),
            KeyCode::Backspace => p.pop_char(),
            KeyCode::Enter => accept_picker(app),
            KeyCode::Esc => close_picker(app),
            _ => {}
        }
        return;
    }

    let streaming = app.is_streaming();
    let slash_open = app.slash.is_some();
    match key.code {
        // Quit / cancel. While streaming, a single Ctrl-C cancels the turn; when
        // idle, Ctrl-C / Ctrl-D need a confirming second press within the window.
        KeyCode::Char('c') if ctrl => {
            if streaming {
                cancel_turn(app);
            } else {
                try_quit(app, primed, 'c');
            }
        }
        KeyCode::Char('d') if ctrl => try_quit(app, primed, 'd'),

        // Ctrl-L: clear the screen, re-anchoring the composer at the top (run by
        // the event loop, which owns the terminal).
        KeyCode::Char('l') if ctrl => app.pending_clear = true,

        // Toggle the detailed token/cost breakdown in the status line.
        KeyCode::Char('t') if ctrl => app.show_stats = !app.show_stats,

        // Ctrl-G: open the composer text in $EDITOR (run by the event loop).
        KeyCode::Char('g') if ctrl && !streaming => {
            app.pending_edit = Some(PendingEdit::Compose);
        }

        // Ctrl-U: delete from the cursor back to the start of the line (shell-style).
        KeyCode::Char('u') if ctrl => {
            app.input.delete_line_by_head();
            app.prune_pastes();
            app.refresh_slash();
        }

        // Esc closes the slash popup first, otherwise cancels an in-flight turn.
        KeyCode::Esc if slash_open => app.slash = None,
        KeyCode::Esc if streaming => cancel_turn(app),

        // Slash-command popup navigation / acceptance.
        KeyCode::Up if slash_open => {
            if let Some(m) = &mut app.slash {
                m.up();
            }
        }
        KeyCode::Down if slash_open => {
            if let Some(m) = &mut app.slash {
                m.down();
            }
        }
        KeyCode::Tab if slash_open => accept_slash(app),
        KeyCode::Enter if slash_open && !shift && !alt => accept_slash(app),

        // Prompt history: cycle previous submissions when the cursor sits at the
        // composer's first/last line (so mid-message arrow navigation still
        // works). Disabled while streaming.
        KeyCode::Up if !streaming && app.at_first_line() => history_prev(app),
        KeyCode::Down if !streaming && app.at_last_line() => history_next(app),

        // Submit on Enter. Shift+Enter and Alt+Enter insert a newline instead.
        KeyCode::Enter if !shift && !alt => submit(app),

        // Everything else edits the composer; recompute the slash popup and drop
        // out of history browsing (the user is editing a new line now). Typing is
        // allowed while streaming so messages can be composed and queued.
        _ => {
            app.input.input(key);
            app.history_idx = None;
            app.history_draft = None;
            app.prune_pastes();
            if !streaming {
                app.refresh_slash();
            }
        }
    }
}

/// Handle a quit chord (Ctrl-C / Ctrl-D): a second press of the *same* chord
/// within [`QUIT_WINDOW`] of the first quits; otherwise arm the prime so the
/// status line shows "Press … again to exit" and the next press can. `primed` is
/// the value [`handle_key`] cleared at the top of this keystroke.
fn try_quit(app: &mut App, primed: Option<(char, Instant)>, key: char) {
    if matches!(primed, Some((c, t)) if c == key && t.elapsed() < QUIT_WINDOW) {
        app.should_quit = true;
    } else {
        app.quit_prime = Some((key, Instant::now()));
    }
}

/// Handle a bracketed paste: large pastes (≥ [`PASTE_COLLAPSE_LINES`] lines or
/// [`PASTE_COLLAPSE_BYTES`] bytes) collapse to a `[Pasted text #N +M lines]`
/// placeholder whose full text is held in `app.pastes` and expanded on submit /
/// in `$EDITOR`; smaller pastes insert inline.
fn handle_paste(app: &mut App, text: String) {
    if text.is_empty() {
        return;
    }
    // Terminals deliver bracketed-paste line breaks as CR (or CRLF), and the
    // textarea only splits on '\n' — so without this a multi-line paste collapses
    // into one line (the CRs are dropped) and never trips the line threshold.
    let text = text.replace("\r\n", "\n").replace('\r', "\n");
    let lines = text.split('\n').count();
    if lines >= PASTE_COLLAPSE_LINES || text.len() >= PASTE_COLLAPSE_BYTES {
        app.paste_counter += 1;
        let placeholder = format!("[Pasted text #{} +{} lines]", app.paste_counter, lines);
        app.input.insert_str(&placeholder);
        app.pastes.push(PasteEntry { placeholder, text });
    } else {
        app.input.insert_str(&text);
    }
    if !app.is_streaming() {
        app.refresh_slash();
    }
}

impl App {
    /// Whether the composer cursor is on the first row (Up recalls history).
    fn at_first_line(&self) -> bool {
        self.input.cursor().0 == 0
    }

    /// Whether the composer cursor is on the last row (Down walks history newer).
    fn at_last_line(&self) -> bool {
        self.input.cursor().0 + 1 >= self.input.lines().len().max(1)
    }

    /// Recompute the slash-command popup from the composer's first line.
    fn refresh_slash(&mut self) {
        let first = self.input.lines().first().map(String::as_str).unwrap_or("");
        self.slash = slash::filter(first);
    }

    /// The composer text with every paste placeholder expanded back to its full
    /// pasted text — what's actually sent / handed to `$EDITOR`.
    fn expanded_input(&self) -> String {
        let mut text = self.input.lines().join("\n");
        for p in &self.pastes {
            if text.contains(&p.placeholder) {
                text = text.replace(&p.placeholder, &p.text);
            }
        }
        text
    }

    /// Drop paste entries whose placeholder is no longer in the composer (the
    /// user edited or deleted it), keeping `pastes` in sync with the text.
    fn prune_pastes(&mut self) {
        if self.pastes.is_empty() {
            return;
        }
        let hay = self.input.lines().join("\n");
        self.pastes.retain(|p| hay.contains(&p.placeholder));
    }

    /// The "press again to exit" hint, while a quit chord is armed.
    pub(crate) fn quit_hint(&self) -> Option<&'static str> {
        self.quit_prime.map(|(c, _)| match c {
            'd' => "Press Ctrl-D again to exit",
            _ => "Press Ctrl-C again to exit",
        })
    }

    /// Number of messages queued behind the streaming turn.
    pub(crate) fn queued_len(&self) -> usize {
        self.queued.len()
    }

    /// Queued messages, for the streaming preview.
    pub(crate) fn queued(&self) -> &[String] {
        &self.queued
    }

    /// While streaming, whether the user is actively composing/queuing (so the
    /// viewport reveals the composer + queue below the live tail). When false the
    /// streaming view stays lean (just the in-flight tail).
    pub(crate) fn composing(&self) -> bool {
        !self.queued.is_empty() || self.input.lines().iter().any(|l| !l.is_empty())
    }
}

/// Replace the composer with `text`, preserving the placeholder + styling that
/// [`fresh_input`] sets. Used to load history / $EDITOR / autocomplete results.
fn input_with_text(text: &str) -> TextArea<'static> {
    let mut input = fresh_input();
    input.insert_str(text);
    input
}

/// Up: recall an older prompt. Stashes the in-progress draft the first time.
fn history_prev(app: &mut App) {
    if app.history.is_empty() {
        return;
    }
    let idx = match app.history_idx {
        None => {
            app.history_draft = Some(app.input.lines().join("\n"));
            app.history.len()
        }
        Some(i) => i,
    };
    if idx == 0 {
        return; // already at the oldest
    }
    let new = idx - 1;
    app.history_idx = Some(new);
    app.input = input_with_text(&app.history[new]);
    app.pastes.clear();
    app.refresh_slash();
}

/// Down: walk toward newer prompts, restoring the stashed draft past the end.
fn history_next(app: &mut App) {
    let Some(idx) = app.history_idx else {
        return;
    };
    let new = idx + 1;
    if new >= app.history.len() {
        app.history_idx = None;
        let draft = app.history_draft.take().unwrap_or_default();
        app.input = input_with_text(&draft);
    } else {
        app.history_idx = Some(new);
        app.input = input_with_text(&app.history[new]);
    }
    app.pastes.clear();
    app.refresh_slash();
}

/// Accept the highlighted slash command: arg commands prefill the composer for
/// the user to type the value; the rest execute immediately.
fn accept_slash(app: &mut App) {
    if app.is_streaming() {
        return;
    }
    let Some(menu) = app.slash.take() else {
        return;
    };
    let cmd = menu.selected();
    // Replacing the composer drops any paste placeholders it held.
    app.pastes.clear();
    if cmd.needs_arg {
        // Prefill "/name " — the trailing space closes the popup (see
        // slash::filter) and leaves the cursor ready for the argument.
        app.input = input_with_text(&format!("/{} ", cmd.name));
    } else {
        app.input = fresh_input();
        dispatch_slash(app, cmd.name, "");
    }
}

/// Push a dim inline notice into the transcript (committed to scrollback).
fn push_notice(app: &mut App, msg: impl Into<String>) {
    app.conv.transcript.push(RenderedBlock::Notice(msg.into()));
}

fn submit(app: &mut App) {
    // Expand any pasted-text placeholders to their full text before sending.
    let text = app.expanded_input();
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return;
    }

    // A leading `/` is a slash command, not a prompt — dispatch it locally.
    // Commands mutate session state, so they can't be queued mid-turn.
    if let Some(rest) = trimmed.strip_prefix('/') {
        if app.is_streaming() {
            push_notice(app, "finish the current turn before running a /command");
            return;
        }
        let (name, arg) = match rest.split_once(char::is_whitespace) {
            Some((n, a)) => (n, a.trim()),
            None => (rest, ""),
        };
        let name = name.to_string();
        let arg = arg.to_string();
        app.input = fresh_input();
        app.pastes.clear();
        app.slash = None;
        dispatch_slash(app, &name, &arg);
        return;
    }

    let text = trimmed.to_string();
    // Record the prompt in this directory's history (in-memory + on disk).
    app.history.push(text.clone());
    hagent_core::history::append(&app.cwd, &text);
    app.history_idx = None;
    app.history_draft = None;

    // While a turn streams, queue the message; it's dispatched when the turn
    // settles back to Normal (see `drain_queue`). Otherwise send it now.
    if app.is_streaming() {
        app.queued.push(text);
        app.input = fresh_input();
        app.pastes.clear();
        app.slash = None;
        return;
    }

    app.conv.push_user(text.clone());
    app.input = fresh_input();
    app.pastes.clear();
    app.slash = None;
    begin_turn(app, Some(text));
}

/// Run a slash command. Unknown commands surface a notice rather than being
/// sent to the model. Commands needing an argument show a usage hint when it's
/// missing.
fn dispatch_slash(app: &mut App, name: &str, arg: &str) {
    match name {
        "resume" => open_resume_picker(app),
        "rewind" => open_rewind_picker(app),
        "clear" => clear_conversation(app),
        "settings" => open_settings_editor(app),
        "reasoning" => {
            app.hide_reasoning = !app.hide_reasoning;
            push_notice(
                app,
                format!(
                    "reasoning {}",
                    if app.hide_reasoning {
                        "hidden"
                    } else {
                        "shown"
                    }
                ),
            );
        }
        "output" => {
            app.hide_tool_output = !app.hide_tool_output;
            push_notice(
                app,
                format!(
                    "tool output {}",
                    if app.hide_tool_output {
                        "hidden"
                    } else {
                        "shown"
                    }
                ),
            );
        }
        "model" => {
            if arg.is_empty() {
                open_model_picker(app); // fetch the server's list and pick
            } else {
                change_model(app, arg);
            }
        }
        "effort" => open_effort_picker(app),
        "server" => {
            if arg.is_empty() {
                push_notice(app, "usage: /server <base-url>");
            } else {
                change_server(app, arg);
            }
        }
        "bootstrap" => trigger_bootstrap(app),
        "mcp" => handle_mcp_command(app, arg),
        other => push_notice(app, format!("unknown command: /{other}")),
    }
}

/// `/mcp` opens the interactive menu; `/mcp <sub> …` runs a subcommand directly.
fn handle_mcp_command(app: &mut App, arg: &str) {
    let arg = arg.trim();
    if arg.is_empty() {
        open_mcp_menu(app);
        return;
    }
    let mut parts = arg.split_whitespace();
    let sub = parts.next().unwrap_or("");
    let label = parts.next().unwrap_or("");
    match sub {
        "list" => list_mcp_servers_notice(app),
        "login" if label.is_empty() => push_notice(app, "usage: /mcp login <server>"),
        "login" => mcp_login_command(app, label),
        "logout" if label.is_empty() => push_notice(app, "usage: /mcp logout <server>"),
        "logout" => mcp_logout_command(app, label),
        // `label` here is the URL; an optional explicit label follows it.
        "add" if label.is_empty() => push_notice(app, "usage: /mcp add <url> [label]"),
        "add" => mcp_add_command(app, label, parts.next().unwrap_or("")),
        other => push_notice(
            app,
            format!("unknown /mcp subcommand '{other}' (use: list, login, logout, add)"),
        ),
    }
}

/// `/mcp add <url> [label]`: validate the URL, derive a label if needed, then
/// open a picker to choose the destination file (`.mcp.json` or `config.toml`).
fn mcp_add_command(app: &mut App, url: &str, label_arg: &str) {
    // Validate the URL up front so the picker only opens for a real http(s) URL.
    // (`suggest_label` returning Some confirms it parses with a host.)
    let scheme_ok = url.starts_with("http://") || url.starts_with("https://");
    let suggested = hagent_core::suggest_label(url);
    if !scheme_ok || suggested.is_none() {
        push_notice(app, format!("invalid MCP server URL: {url}"));
        return;
    }
    let user_label = (!label_arg.is_empty()).then(|| label_arg.to_string());
    // A provisional label (the user's, else the hostname heuristic) for the
    // up-front dup check + notice; when auto-derived, `add_server` may upgrade it
    // to the server's well-known name.
    let provisional = user_label
        .clone()
        .or(suggested)
        .unwrap_or_else(|| "server".to_string());
    // Avoid clobbering an existing label this session.
    if app
        .settings
        .mcp_servers
        .iter()
        .any(|s| s.server_label == provisional)
    {
        push_notice(
            app,
            format!(
                "an MCP server labelled '{provisional}' already exists — pass a different label"
            ),
        );
        return;
    }
    app.pending_mcp_add = Some((user_label, url.to_string()));
    open_mcp_add_dest_picker(app);
}

/// Open the two-choice destination picker for a pending `/mcp add`.
fn open_mcp_add_dest_picker(app: &mut App) {
    let has_config = app.config_path.is_some();
    let mut items = vec![PickerItem {
        label: "project — ./.mcp.json".into(),
        detail: "shared with the project".into(),
        id: None,
        point: None,
        value: Some("json".into()),
    }];
    if has_config {
        items.push(PickerItem {
            label: "user — config.toml".into(),
            detail: "your hagent config".into(),
            id: None,
            point: None,
            value: Some("toml".into()),
        });
    }
    app.picker = Some(Picker::new(PickerKind::McpAddDest, items));
    app.mode = Mode::Picker;
}

/// Open the top-level `/mcp` menu: a picker of configured servers (each showing
/// its auth status) plus an "add a server" row.
fn open_mcp_menu(app: &mut App) {
    let mut items: Vec<PickerItem> = app
        .settings
        .mcp_servers
        .iter()
        .map(|s| {
            let auth = mcp_auth_status(s, &app.token_store);
            let detail = format!("{} · approval: {}", auth, s.require_approval);
            PickerItem {
                label: s.server_label.clone(),
                detail,
                id: None,
                point: None,
                value: Some(s.server_label.clone()),
            }
        })
        .collect();
    items.push(PickerItem {
        label: "＋ add a server…".into(),
        detail: "from a URL".into(),
        id: None,
        point: None,
        value: Some(MCP_MENU_ADD.to_string()),
    });
    app.picker = Some(Picker::new(PickerKind::McpMenu, items));
    app.mode = Mode::Picker;
}

/// Act on a `/mcp` menu selection: the "add" row, or a server (→ its action
/// submenu, which always includes an approval toggle and, for OAuth servers,
/// login/logout).
fn mcp_menu_select(app: &mut App, value: &str) {
    if value == MCP_MENU_ADD {
        // Prefill the composer so the user types the URL; submit runs the add
        // flow (which then opens the destination picker).
        app.input = input_with_text("/mcp add ");
        app.pastes.clear();
        return;
    }
    let label = value.to_string();
    let Some(server) = app
        .settings
        .mcp_servers
        .iter()
        .find(|s| s.server_label == label)
    else {
        return;
    };
    app.mcp_menu_server = Some(label.clone());

    // Always offer the approval toggle, showing the current setting; ⏎ flips it.
    let mut items = vec![PickerItem {
        label: format!("approval: {}", server.require_approval),
        detail: "⏎ toggle".into(),
        id: None,
        point: None,
        value: Some("approval".into()),
    }];

    // OAuth servers also offer login/logout.
    if server.needs_oauth() {
        let has_token = app.token_store.load(&server.server_url).is_some();
        items.push(PickerItem {
            label: if has_token {
                "re-authenticate".into()
            } else {
                "log in".into()
            },
            detail: "opens your browser".into(),
            id: None,
            point: None,
            value: Some("login".into()),
        });
        if has_token {
            items.push(PickerItem {
                label: "log out".into(),
                detail: "remove the stored token".into(),
                id: None,
                point: None,
                value: Some("logout".into()),
            });
        }
    }

    app.picker = Some(Picker::new(PickerKind::McpServerActions, items));
    app.mode = Mode::Picker;
}

/// Toggle `require_approval` for an MCP server between "always" and "never",
/// both in the live session settings and persisted to whichever file declares
/// it — `config.toml` or `./.mcp.json`.
fn toggle_mcp_approval(app: &mut App, label: &str) {
    let Some(server) = app
        .settings
        .mcp_servers
        .iter_mut()
        .find(|s| s.server_label == label)
    else {
        push_notice(app, format!("no MCP server '{label}' configured"));
        return;
    };
    let new_val = if server.require_approval == "always" {
        "never"
    } else {
        "always"
    };
    server.require_approval = new_val.to_string();
    push_notice(app, format!("{label} — require_approval set to {new_val}"));

    // Persist to wherever the server is declared. config.toml wins over
    // .mcp.json on merge, so try it first, then fall back to ./.mcp.json.
    match persist_mcp_approval_anywhere(app, label, new_val) {
        Ok(true) => {}
        Ok(false) => push_notice(
            app,
            "not found in config.toml or .mcp.json; change applies this session only".to_string(),
        ),
        Err(e) => push_notice(
            app,
            format!("couldn't persist approval change: {e} (applied this session only)"),
        ),
    }
}

/// Write a `require_approval` change back to whichever file declares `label`:
/// `config.toml` first (it wins the merge), then `./.mcp.json`. Returns whether
/// a file was updated (`false` = the server is declared in neither, so the
/// change is session-only).
fn persist_mcp_approval_anywhere(app: &App, label: &str, new_val: &str) -> Result<bool, String> {
    if let Some(path) = &app.config_path {
        if hagent_core::persist_mcp_approval(path, label, new_val).map_err(|e| e.to_string())? {
            return Ok(true);
        }
    }
    hagent_core::mcp::persist_mcp_approval_json(&app.cwd, label, new_val).map_err(|e| e.to_string())
}

/// Start an OAuth login for `label` directly (no y/n prompt — the user already
/// asked for it via the menu or by adding the server). Goes straight to the
/// "authenticating…" panel.
fn start_mcp_login_for(app: &mut App, label: String) {
    let Some(server) = app
        .settings
        .mcp_servers
        .iter()
        .find(|s| s.server_label == label)
        .cloned()
    else {
        return;
    };
    app.mcp_login = McpLogin::Running(label);
    app.mode = Mode::AwaitingMcpLogin;
    app.spinner = 0;
    app.turn_start = None;
    net::spawn_mcp_login(server, app.token_store.clone(), app.tx.clone());
}

/// List configured MCP servers and their auth status as inline notices.
fn list_mcp_servers_notice(app: &mut App) {
    if app.settings.mcp_servers.is_empty() {
        push_notice(
            app,
            "no MCP servers configured (add them to ./.mcp.json or config.toml)",
        );
        return;
    }
    // Format first (immutable borrows) so the pushes can borrow `app` mutably.
    let lines: Vec<String> = app
        .settings
        .mcp_servers
        .iter()
        .map(|s| {
            format!(
                "{} — {} [{}] approval: {}",
                s.server_label,
                s.server_url,
                mcp_auth_status(s, &app.token_store),
                s.require_approval
            )
        })
        .collect();
    for line in lines {
        push_notice(app, line);
    }
}

/// A short auth-status string for one server (for `/mcp list`).
fn mcp_auth_status(server: &McpServerSettings, store: &TokenStore) -> String {
    match &server.auth {
        AuthKind::None => "no auth".into(),
        AuthKind::Bearer(_) => "static bearer".into(),
        AuthKind::Header { .. } => "static header".into(),
        AuthKind::Oauth(_) => match store.load(&server.server_url) {
            None => "oauth: needs login".into(),
            Some(rec) => match rec.expires_at_ms {
                None => "oauth: authenticated".into(),
                Some(exp) => {
                    let now = hagent_core::convlog::now_ms();
                    if exp <= now {
                        "oauth: token expired".into()
                    } else {
                        format!("oauth: authenticated (~{} min left)", (exp - now) / 60_000)
                    }
                }
            },
        },
    }
}

/// `/mcp login <server>`: open the OAuth login panel for a server (no parked
/// turn — this just authenticates).
fn mcp_login_command(app: &mut App, label: &str) {
    if app.is_streaming() {
        push_notice(app, "finish the current turn before /mcp login");
        return;
    }
    match app
        .settings
        .mcp_servers
        .iter()
        .find(|s| s.server_label == label)
    {
        None => push_notice(app, format!("no MCP server '{label}' configured")),
        Some(s) if !s.needs_oauth() => {
            push_notice(app, format!("server '{label}' does not use OAuth"))
        }
        Some(_) => offer_mcp_login(app, label.to_string()),
    }
}

/// `/mcp logout <server>`: drop the stored token and clear the resolved bearer.
fn mcp_logout_command(app: &mut App, label: &str) {
    let Some(url) = app
        .settings
        .mcp_servers
        .iter()
        .find(|s| s.server_label == label)
        .map(|s| s.server_url.clone())
    else {
        push_notice(app, format!("no MCP server '{label}' configured"));
        return;
    };
    let _ = app.token_store.delete(&url);
    if let Some(s) = app
        .settings
        .mcp_servers
        .iter_mut()
        .find(|s| s.server_label == label)
    {
        s.resolved_authorization = None;
    }
    push_notice(app, format!("logged out of MCP server '{label}'"));
}

/// Persist a top-level config setting, surfacing any failure as a notice.
fn persist_or_notice(app: &mut App, key: &str, value: &str) {
    let Some(path) = app.config_path.clone() else {
        push_notice(app, "no config file; change applies this session only");
        return;
    };
    if let Err(e) = hagent_core::persist_setting(&path, key, value) {
        push_notice(app, format!("couldn't save {key} to config: {e}"));
    }
}

/// `/model`: switch the model for the session and persist it as the default.
fn change_model(app: &mut App, model: &str) {
    app.settings.model = model.to_string();
    persist_or_notice(app, "model", model);
    push_notice(app, format!("model → {model}"));
}

/// The reasoning effort levels offered by `/effort`. `off` disables reasoning.
/// `xhigh`/`max` are the adaptive-Opus / GPT-5.x tiers above `high` (providers
/// that don't support them clamp down to `high`).
const EFFORT_LEVELS: &[&str] = &["minimal", "low", "medium", "high", "xhigh", "max", "off"];

/// `/effort`: set the reasoning effort for the session and persist it. `off`
/// clears the reasoning config (no thinking requested).
fn change_effort(app: &mut App, level: &str) {
    use hagent_core::protocol::request::ReasoningConfig;
    if level == "off" {
        app.settings.reasoning = None;
    } else {
        // Keep any existing summary setting; default it to "auto" when unset.
        let summary = app
            .settings
            .reasoning
            .as_ref()
            .and_then(|r| r.summary.clone())
            .or_else(|| Some("auto".to_string()));
        app.settings.reasoning = Some(ReasoningConfig {
            effort: Some(level.to_string()),
            summary,
        });
    }
    match app.config_path.clone() {
        Some(path) => {
            if let Err(e) = hagent_core::persist_reasoning_effort(&path, level) {
                push_notice(app, format!("couldn't save effort to config: {e}"));
            }
        }
        None => push_notice(app, "no config file; effort applies this session only"),
    }
    push_notice(app, format!("reasoning effort → {level}"));
}

/// `/model` (no arg): open a picker and fetch the server's model list to fill
/// it (the picker shows "fetching…" until [`AppEvent::ModelsLoaded`] arrives).
fn open_model_picker(app: &mut App) {
    let mut picker = Picker::new(PickerKind::Model, Vec::new());
    picker.loading = true;
    app.picker = Some(picker);
    app.mode = Mode::Picker;
    net::spawn_list_models(app.client.clone(), app.tx.clone());
}

/// Fold the fetched model list into the open `/model` picker (or surface an
/// error / "no models" notice and close it). Each row's detail shows the
/// input/output cost per 1M tokens when the server reports pricing.
fn finish_models_loaded(app: &mut App, result: std::result::Result<Vec<ModelInfo>, String>) {
    // Ignore a stale result if the model picker was already closed.
    let Some(p) = app.picker.as_mut().filter(|p| p.kind == PickerKind::Model) else {
        return;
    };
    // On a failed or empty fetch, keep the picker open with the message shown:
    // the user can still type or paste a model name and choose it via the
    // freeform `use "<name>"` row (no server list required).
    match result {
        Ok(models) if models.is_empty() => {
            p.error = Some("server returned no models".into());
            p.set_items(Vec::new());
        }
        Ok(models) => {
            let items = models
                .into_iter()
                .map(|m| PickerItem {
                    detail: model_price_detail(&m),
                    label: m.id.clone(),
                    id: None,
                    point: None,
                    value: Some(m.id),
                })
                .collect();
            p.error = None;
            p.set_items(items);
        }
        Err(e) => {
            p.error = Some(format!("couldn't list models: {e}"));
            p.set_items(Vec::new());
        }
    }
}

/// A compact "input/output cost per 1M tokens" string for a model picker row, or
/// empty when the server reports no pricing for it.
fn model_price_detail(m: &ModelInfo) -> String {
    match (m.input_per_m, m.output_per_m) {
        (Some(i), Some(o)) => format!("{} / {} per 1M tok", fmt_price(i), fmt_price(o)),
        (Some(i), None) => format!("{} in / 1M tok", fmt_price(i)),
        (None, Some(o)) => format!("{} out / 1M tok", fmt_price(o)),
        (None, None) => String::new(),
    }
}

/// Format a USD-per-1M-tokens price compactly: `$3`, `$0.15`, `$1.20` → `$1.2`
/// (trailing zeros trimmed).
fn fmt_price(p: f64) -> String {
    let s = if p >= 1.0 {
        format!("{p:.2}")
    } else {
        format!("{p:.3}")
    };
    let trimmed = s.trim_end_matches('0').trim_end_matches('.');
    format!("${trimmed}")
}

/// `/effort`: open a picker over the reasoning effort levels.
fn open_effort_picker(app: &mut App) {
    let current = app
        .settings
        .reasoning
        .as_ref()
        .and_then(|r| r.effort.clone())
        .unwrap_or_else(|| "off".to_string());
    let items: Vec<PickerItem> = EFFORT_LEVELS
        .iter()
        .map(|lvl| PickerItem {
            label: (*lvl).to_string(),
            detail: if *lvl == current {
                "current".into()
            } else {
                String::new()
            },
            id: None,
            point: None,
            value: Some((*lvl).to_string()),
        })
        .collect();
    let selected = EFFORT_LEVELS
        .iter()
        .position(|l| *l == current)
        .unwrap_or(0);
    let mut picker = Picker::new(PickerKind::Effort, items);
    picker.selected = selected;
    app.picker = Some(picker);
    app.mode = Mode::Picker;
}

/// `/server`: switch the backend for the session and persist it. Rebuilds the
/// client, starts a fresh `previous_response_id` chain (server-side history is
/// per-backend), and re-resolves the cached skill id for the new server.
fn change_server(app: &mut App, base_url: &str) {
    let client = match Client::new(base_url.to_string(), app.settings.api_key.clone()) {
        Ok(c) => c.with_logger(app.logger.clone()),
        Err(e) => {
            push_notice(app, format!("invalid server URL: {e}"));
            return;
        }
    };
    app.client = client;
    app.settings.base_url = base_url.to_string();
    // Server-side history can't carry across backends — start a fresh chain.
    app.conv.last_response_id = None;
    app.conv.container_id = None;
    // The cached file-editing skill is per-server; re-resolve for the new one.
    app.settings.skill_id = app
        .config_path
        .as_deref()
        .and_then(|p| FileConfig::load(p).ok().flatten())
        .and_then(|f| {
            hagent_core::lookup_skill_id(&f.skills, base_url, app.settings.api_key.as_deref())
        });
    persist_or_notice(app, "base_url", base_url);
    push_notice(app, format!("server → {base_url} (new chain)"));
}

/// `/settings`: queue a `$EDITOR` session on the config file (run by the event
/// loop). Resolves the path, falling back to the default location.
fn open_settings_editor(app: &mut App) {
    let path = app
        .config_path
        .clone()
        .or_else(hagent_core::config::default_config_path);
    match path {
        Some(p) => app.pending_edit = Some(PendingEdit::Settings(p)),
        None => push_notice(app, "no config path available"),
    }
}

/// `/resume`: open a picker over the stored conversations.
fn open_resume_picker(app: &mut App) {
    let Some(dir) = app.settings.log_dir.clone() else {
        push_notice(app, "logging is disabled — nothing to resume");
        return;
    };
    let convs = hagent_core::convlog::list_conversations(&dir);
    if convs.is_empty() {
        push_notice(app, "no past conversations found");
        return;
    }
    let current = app.logger.as_ref().map(|l| l.conversation_id().to_string());
    let items: Vec<PickerItem> = convs
        .into_iter()
        .map(|c| {
            let here = current.as_deref() == Some(c.id.as_str());
            PickerItem {
                label: if c.title.is_empty() {
                    "(no messages yet)".to_string()
                } else {
                    c.title
                },
                detail: format!("{}{}", c.model, if here { " · current" } else { "" }),
                id: Some(c.id),
                point: None,
                value: None,
            }
        })
        .collect();
    app.picker = Some(Picker::new(PickerKind::Resume, items));
    app.mode = Mode::Picker;
}

/// `/rewind`: open a picker over this conversation's user prompts.
fn open_rewind_picker(app: &mut App) {
    if app.conv.is_in_flight() {
        push_notice(app, "can't rewind while a turn is in flight");
        return;
    }
    let targets: Vec<(usize, String)> = app
        .conv
        .rewind_targets()
        .into_iter()
        .map(|(i, t)| (i, t.lines().next().unwrap_or("").to_string()))
        .collect();
    if targets.is_empty() {
        push_notice(app, "nothing to rewind to yet");
        return;
    }
    let selected = targets.len().saturating_sub(1);
    let items: Vec<PickerItem> = targets
        .into_iter()
        .map(|(i, label)| PickerItem {
            label,
            detail: format!("turn {}", i + 1),
            id: None,
            point: Some(i),
            value: None,
        })
        .collect();
    let mut picker = Picker::new(PickerKind::Rewind, items);
    picker.selected = selected;
    app.picker = Some(picker);
    app.mode = Mode::Picker;
}

/// Close the picker and return to normal input.
fn close_picker(app: &mut App) {
    app.picker = None;
    app.mode = Mode::Normal;
}

/// Act on the highlighted picker row, then close it.
fn accept_picker(app: &mut App) {
    let Some(picker) = app.picker.take() else {
        app.mode = Mode::Normal;
        return;
    };
    app.mode = Mode::Normal;
    let Some(row) = picker.selected_row() else {
        return;
    };
    // Extract owned values up front so we can freely mutate `app` below.
    let model = match (&picker.kind, &row) {
        (PickerKind::Model, PickerRow::Custom(f)) => Some(f.to_string()),
        (PickerKind::Model, PickerRow::Item(it)) => it.value.clone(),
        _ => None,
    };
    let item = match row {
        PickerRow::Item(it) => Some((it.id.clone(), it.point, it.value.clone())),
        PickerRow::Custom(_) => None,
    };
    match picker.kind {
        PickerKind::Resume => {
            if let Some((Some(id), _, _)) = item {
                resume_conversation(app, &id);
            }
        }
        PickerKind::Rewind => {
            if let Some((_, Some(point), _)) = item {
                app.conv.rewind_to(point);
                // Already-committed scrollback can't be un-printed; stop the
                // flush cursor just before the new "rewound" Notice so only it
                // commits (below the now-stale history that stays on screen).
                app.flushed_blocks = app.conv.transcript.len().saturating_sub(1);
                app.flushed_lines = 0;
                app.save_snapshot();
            }
        }
        PickerKind::Model => {
            if let Some(model) = model.filter(|m| !m.is_empty()) {
                change_model(app, &model);
                // Chain straight into picking the reasoning effort, so the user
                // doesn't have to follow up with `/effort`.
                open_effort_picker(app);
            }
        }
        PickerKind::Effort => {
            if let Some((_, _, Some(v))) = item {
                change_effort(app, &v);
            }
        }
        PickerKind::McpAddDest => {
            if let Some((_, _, Some(dest))) = item {
                start_mcp_add(app, &dest);
            }
        }
        PickerKind::McpMenu => {
            if let Some((_, _, Some(value))) = item {
                mcp_menu_select(app, &value);
            }
        }
        PickerKind::McpServerActions => {
            let label = app.mcp_menu_server.take();
            if let (Some(label), Some((_, _, Some(action)))) = (label, item) {
                match action.as_str() {
                    "approval" => {
                        toggle_mcp_approval(app, &label);
                        // Stay in the action menu so the user can see the new
                        // setting and keep toggling; rebuilding it refreshes the
                        // row's "currently … → toggle to …" hint.
                        mcp_menu_select(app, &label);
                    }
                    "login" => start_mcp_login_for(app, label),
                    "logout" => mcp_logout_command(app, &label),
                    _ => {}
                }
            }
        }
    }
}

/// Kick off the file write for a pending `/mcp add` to the chosen destination
/// (`"json"` → ./.mcp.json, `"toml"` → config.toml). Probes + writes off-thread.
fn start_mcp_add(app: &mut App, dest: &str) {
    let Some((user_label, url)) = app.pending_mcp_add.take() else {
        return;
    };
    let target = match dest {
        "toml" => match &app.config_path {
            Some(path) => hagent_core::AddTarget::ConfigToml(path.clone()),
            None => {
                push_notice(app, "no config file path; can't add to config.toml");
                return;
            }
        },
        _ => hagent_core::AddTarget::McpJson(app.cwd.clone()),
    };
    let provisional = user_label
        .clone()
        .or_else(|| hagent_core::suggest_label(&url))
        .unwrap_or_else(|| "server".to_string());
    push_notice(app, format!("adding MCP server '{provisional}'…"));
    net::spawn_mcp_add(target, user_label, url, app.tx.clone());
}

/// Fold a finished `/mcp add` into the live session: add the resolved server so
/// it's usable immediately, with a notice (and a login hint for OAuth servers).
fn finish_mcp_added(app: &mut App, result: std::result::Result<McpServerSettings, String>) {
    match result {
        Ok(server) => {
            let label = server.server_label.clone();
            let description = server.server_description.clone();
            let needs_login = server.needs_oauth();
            // Replace any same-label entry (shouldn't exist — we checked) or add.
            if let Some(slot) = app
                .settings
                .mcp_servers
                .iter_mut()
                .find(|s| s.server_label == label)
            {
                *slot = server;
            } else {
                app.settings.mcp_servers.push(server);
            }
            let notice = match description {
                Some(name) => format!("added MCP server '{label}' ({name})"),
                None => format!("added MCP server '{label}'"),
            };
            push_notice(app, notice);
            // If it needs OAuth, start the login now rather than just suggesting it.
            if needs_login {
                start_mcp_login_for(app, label);
            }
        }
        Err(e) => app.conv.transcript.push(RenderedBlock::Error {
            message: format!("couldn't add MCP server: {e}"),
            code: None,
        }),
    }
}

/// Switch the live session to a previously-stored conversation: reload its
/// snapshot, reopen its log, rebuild the client around that logger, and reset
/// the flush cursor so the restored transcript re-commits to scrollback.
fn resume_conversation(app: &mut App, id: &str) {
    let Some(dir) = app.settings.log_dir.clone() else {
        push_notice(app, "logging is disabled — can't resume");
        return;
    };
    let snapshot = match hagent_core::convlog::load_snapshot(&dir, id) {
        Ok(s) => s,
        Err(e) => {
            push_notice(app, format!("couldn't load {id}: {e}"));
            return;
        }
    };
    if snapshot.base_url != app.settings.base_url {
        push_notice(
            app,
            format!(
                "note: {id} was on {} (you're on {})",
                snapshot.base_url, app.settings.base_url
            ),
        );
    }
    // Reopen logging for the resumed conversation and rebuild the client so new
    // turns append to its log rather than the current conversation's.
    let logger = match hagent_core::ConvLogger::resume(&dir, id, &app.settings) {
        Ok(l) => Some(l),
        Err(e) => {
            push_notice(app, format!("couldn't reopen log for {id}: {e}"));
            None
        }
    };
    match Client::new(app.settings.base_url.clone(), app.settings.api_key.clone()) {
        Ok(c) => app.client = c.with_logger(logger.clone()),
        Err(e) => push_notice(app, format!("couldn't rebuild client: {e}")),
    }
    app.logger = logger;
    app.created_ms = snapshot.created_ms;
    app.conv = Conversation::restore(&snapshot);
    // Re-commit the restored transcript into scrollback on the next pass.
    app.flushed_blocks = 0;
    app.flushed_lines = 0;
    push_notice(app, format!("resumed {id}"));
    app.save_snapshot();
}

/// `/clear`: drop the current conversation and start a brand-new one — a fresh
/// transcript, `previous_response_id` chain, container, and running totals. When
/// logging is on, the new conversation gets its own log directory (so the old
/// one is preserved and independently resumable) and the client is rebuilt
/// around the new logger. The screen is cleared and the welcome banner re-shown
/// so the new conversation opens looking like launch.
fn clear_conversation(app: &mut App) {
    if app.is_streaming() {
        push_notice(app, "finish the current turn before clearing");
        return;
    }
    // A fresh conversation: empty transcript, no chain pointer / container,
    // zeroed totals, nothing queued.
    app.conv = Conversation::new();
    app.created_ms = hagent_core::convlog::now_ms();
    app.queued.clear();

    // Open a new log directory so the new conversation records separately, and
    // rebuild the client around the new logger so its turns log there. On any
    // failure we degrade to no-logging (`logger = None`). This is load-bearing:
    // `app.logger` must NEVER be left pointing at the *old* conversation, because
    // the `save_snapshot` below writes the freshly-cleared (empty) `app.conv` —
    // through a stale logger that would overwrite the old conversation's
    // snapshot and keep new turns logging into its directory. So compute the
    // logger, then adopt it (and the matching client) unconditionally — the same
    // shape as `resume_conversation`.
    if let Some(dir) = app.settings.log_dir.clone() {
        let logger = match ConvLogger::create(&dir, &app.settings) {
            Ok(l) => Some(l),
            Err(e) => {
                push_notice(app, format!("couldn't open new log: {e}"));
                None
            }
        };
        match Client::new(app.settings.base_url.clone(), app.settings.api_key.clone()) {
            Ok(c) => app.client = c.with_logger(logger.clone()),
            Err(e) => push_notice(app, format!("couldn't rebuild client: {e}")),
        }
        app.logger = logger;
    }

    // Re-anchor the visible screen: `clear_screen` scrolls the prior rows up into
    // native scrollback (preserved, like Ctrl-L — not erased) and `pending_welcome`
    // re-commits the settled banner, so the new conversation opens looking like
    // launch. Both run in `event_loop`, which owns the terminal.
    app.flushed_blocks = 0;
    app.flushed_lines = 0;
    app.committed_nonempty = false;
    app.pending_clear = true;
    app.pending_welcome = true;

    // Confirm with the new conversation's id, then write the opening snapshot so
    // the new conversation is discoverable (and `--continue`-able) before its
    // first turn. The notice is pushed first so it rides that snapshot, matching
    // `resume_conversation`.
    match app.logger.as_ref().map(|l| l.conversation_id().to_string()) {
        Some(id) => push_notice(app, format!("cleared — new conversation {id}")),
        None => push_notice(app, "cleared — new conversation"),
    }
    app.save_snapshot();
}

fn cancel_turn(app: &mut App) {
    // Cancelling discards any messages queued behind this turn — the user is
    // stopping, not continuing.
    app.queued.clear();
    // A turn still resolving MCP tokens has no stream to cancel yet; drop it and
    // settle locally so the UI returns to the composer.
    let resolving = app.pending_turn.take().is_some() && app.cancel.is_none();
    if let Some(token) = app.cancel.take() {
        token.cancel();
    } else if resolving {
        app.conv.fail("turn cancelled");
        end_turn(app, false);
    }
}

/// Record an approve/deny decision. Once all pending approvals are decided,
/// dispatch a continuation turn carrying the `mcp_approval_response` items.
fn decide_approval(app: &mut App, approve: bool) {
    let still_pending = app.conv.decide_approval(approve);
    if still_pending {
        return; // stay in the modal for the next approval
    }
    // Re-resolve MCP tokens before resuming (a long approval pause can cross a
    // token's expiry; Hadrian needs a fresh bearer on this turn too).
    begin_turn(app, None);
}

fn fresh_input() -> TextArea<'static> {
    let mut input = TextArea::default();
    input.set_placeholder_text("Type a message");
    // ratatui-textarea underlines the whole cursor line by default; clear it so
    // the input text isn't underlined.
    input.set_cursor_line_style(Style::default());
    input
}

impl App {
    /// Has block `i` reached a stable state that's safe to freeze into immutable
    /// scrollback? Text/reasoning are settled once the turn ends or a later
    /// block exists (the model moved on); tool/status blocks once their status
    /// is terminal; approval prompts once decided; user/error/marker always.
    fn is_block_final(&self, i: usize) -> bool {
        let is_last = i + 1 == self.conv.transcript.len();
        let turn_over = !self.is_streaming();
        match &self.conv.transcript[i] {
            RenderedBlock::UserMsg(_)
            | RenderedBlock::Error { .. }
            | RenderedBlock::CompactionMarker
            | RenderedBlock::Notice(_) => true,
            RenderedBlock::AssistantText { .. } | RenderedBlock::Reasoning { .. } => {
                turn_over || !is_last
            }
            // A shell block's output lands in a *separate* `shell_call_output`
            // item that the server emits after the `shell_call` reports
            // `completed` (see hagent-core). Committing on status alone would
            // race that item and freeze an empty block into immutable
            // scrollback, so also require its output to have arrived.
            RenderedBlock::ShellCall {
                status,
                output_seen,
                ..
            } => {
                turn_over
                    || (matches!(status, CallStatus::Completed | CallStatus::Failed)
                        && *output_seen)
            }
            RenderedBlock::WebSearch { status }
            | RenderedBlock::FileSearch { status, .. }
            | RenderedBlock::McpCall { status, .. } => {
                turn_over || matches!(status, CallStatus::Completed | CallStatus::Failed)
            }
            RenderedBlock::ApprovalPrompt { decision, .. } => decision.is_some(),
        }
    }

    /// Pull the next chunk of finalized lines to commit to scrollback, advancing
    /// the flush cursor. Returns `None` when nothing more is committable yet.
    /// Each returned chunk already carries its leading blank separator.
    fn next_commit_chunk(&mut self) -> Option<Vec<Line<'static>>> {
        loop {
            let i = self.flushed_blocks;
            let block = self.conv.transcript.get(i)?;
            let final_ = self.is_block_final(i);

            if is_append_only_text(block) {
                let all = block_lines(block, self.spinner, self.render_opts());
                // Hold back the still-growing last line until the block settles.
                let upto = if final_ {
                    all.len()
                } else {
                    all.len().saturating_sub(1)
                };
                if upto > self.flushed_lines {
                    let starts_block = self.flushed_lines == 0;
                    let chunk = self.with_separator(&all[self.flushed_lines..upto], starts_block);
                    self.flushed_lines = upto;
                    return Some(chunk);
                }
                if final_ && self.flushed_lines >= all.len() {
                    self.flushed_blocks += 1;
                    self.flushed_lines = 0;
                    continue; // try the next block in the same pass
                }
                return None; // partial line still streaming
            }

            // Whole-block commit (tools, user message, markers, decided approvals).
            if !final_ {
                return None;
            }
            let lines = block_lines(block, self.spinner, self.render_opts());
            self.flushed_blocks += 1;
            self.flushed_lines = 0;
            if lines.is_empty() {
                continue; // nothing to render (e.g. empty reasoning) — skip
            }
            return Some(self.with_separator(&lines, true));
        }
    }

    /// Prepend the single blank separator line before a block's first lines,
    /// matching the in-transcript rendering, and flag that scrollback is now
    /// non-empty.
    fn with_separator(
        &mut self,
        lines: &[Line<'static>],
        starts_block: bool,
    ) -> Vec<Line<'static>> {
        let mut out = Vec::with_capacity(lines.len() + 1);
        if starts_block && self.committed_nonempty {
            out.push(Line::default());
        }
        out.extend_from_slice(lines);
        self.committed_nonempty = true;
        out
    }

    /// The not-yet-committed in-flight tail to show in the viewport while a turn
    /// streams: the uncommitted lines of the current block plus any later
    /// blocks, with blank separators between. `None` when nothing is pending.
    pub(crate) fn live_tail_lines(&self) -> Option<Vec<Line<'static>>> {
        let mut out: Vec<Line<'static>> = Vec::new();
        for (i, block) in self
            .conv
            .transcript
            .iter()
            .enumerate()
            .skip(self.flushed_blocks)
        {
            let mut bl = block_lines(block, self.spinner, self.render_opts());
            if i == self.flushed_blocks && is_append_only_text(block) {
                bl.drain(..self.flushed_lines.min(bl.len()));
            }
            if bl.is_empty() {
                continue;
            }
            if !out.is_empty() {
                out.push(Line::default());
            }
            out.append(&mut bl);
        }
        (!out.is_empty()).then_some(out)
    }
}

fn spawn_input_reader(tx: UnboundedSender<AppEvent>) -> JoinHandle<()> {
    tokio::spawn(async move {
        let mut events = EventStream::new();
        loop {
            match events.next().await {
                Some(Ok(event)) => {
                    if tx.send(AppEvent::Input(event)).is_err() {
                        break;
                    }
                }
                // A transient read error shouldn't kill input for the session.
                Some(Err(_)) => continue,
                None => break,
            }
        }
    })
}

fn spawn_ticker(tx: UnboundedSender<AppEvent>) {
    tokio::spawn(async move {
        let mut ticker = interval(Duration::from_millis(120));
        loop {
            ticker.tick().await;
            if tx.send(AppEvent::Tick).is_err() {
                break;
            }
        }
    });
}

#[cfg(test)]
mod tests {
    use super::*;
    use hagent_core::config::{ContainerConfig, Settings};
    use hagent_core::protocol::event::StreamEvent;
    use hagent_core::RenderedBlock;
    use ratatui::buffer::Buffer;
    use ratatui::layout::Rect;

    fn test_app() -> App {
        let settings = Settings {
            base_url: "http://localhost:8080/api".into(),
            api_key: None,
            model: "test/smoke".into(),
            instructions: None,
            system_prompt: None,
            tools: vec![],
            vector_store_ids: vec![],
            mcp_servers: vec![],
            compaction: None,
            container: ContainerConfig::default(),
            reasoning: None,
            skill_id: None,
            log_dir: None,
            redact_logs: true,
        };
        let client = Client::new(settings.base_url.clone(), None).unwrap();
        let (tx, _rx) = mpsc::unbounded_channel();
        App::new(
            settings,
            client,
            tx,
            None,
            Overrides::default(),
            None,
            false,
            None,
            None,
        )
    }

    fn ev(json: &str) -> StreamEvent {
        serde_json::from_str(json).unwrap()
    }

    /// Flatten every committable chunk into joined, space-stripped text.
    fn drain_commits(app: &mut App) -> String {
        let mut out = String::new();
        while let Some(chunk) = app.next_commit_chunk() {
            for line in chunk {
                let s: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
                out.push_str(&s);
                out.push('\n');
            }
        }
        out
    }

    #[test]
    fn mcp_needs_login_opens_panel_then_decline_cancels_turn() {
        let mut app = test_app();
        // A turn is parked (Streaming) and token resolution reports a server
        // that needs an interactive login.
        app.pending_turn = Some(PendingTurn {
            user_text: Some("hi".into()),
        });
        app.mode = Mode::Streaming;
        finish_tokens_ready(
            &mut app,
            TokensReady {
                resolved: vec![],
                needs_login: vec!["gh".into()],
            },
        );
        assert_eq!(app.mode, Mode::AwaitingMcpLogin);
        assert!(app.pending_turn.is_some(), "turn stays parked during login");
        assert_eq!(app.mcp_login, McpLogin::Prompt("gh".into()));

        // Declining abandons the turn and returns to the composer.
        decline_mcp_login(&mut app);
        assert_eq!(app.mode, Mode::Normal);
        assert!(app.pending_turn.is_none());
        assert_eq!(app.mcp_login, McpLogin::Inactive);
    }

    #[test]
    fn slash_mcp_subcommands_surface_notices() {
        let mut app = test_app(); // no MCP servers configured
        let has_notice = |app: &App, needle: &str| {
            app.conv
                .transcript
                .iter()
                .any(|b| matches!(b, RenderedBlock::Notice(n) if n.contains(needle)))
        };

        dispatch_slash(&mut app, "mcp", "list");
        assert!(has_notice(&app, "no MCP servers configured"));

        dispatch_slash(&mut app, "mcp", "login");
        assert!(has_notice(&app, "usage: /mcp login"));

        dispatch_slash(&mut app, "mcp", "login ghost");
        assert!(has_notice(&app, "no MCP server 'ghost'"));

        dispatch_slash(&mut app, "mcp", "frobnicate");
        assert!(has_notice(&app, "unknown /mcp subcommand"));
        // None of these started a turn.
        assert_eq!(app.mode, Mode::Normal);
    }

    #[test]
    fn slash_mcp_add_validates_and_opens_dest_picker() {
        let mut app = test_app();
        // An invalid URL surfaces a notice and opens no picker.
        dispatch_slash(&mut app, "mcp", "add notaurl");
        assert!(app.picker.is_none());
        assert!(app.conv.transcript.iter().any(
            |b| matches!(b, RenderedBlock::Notice(n) if n.contains("invalid MCP server URL"))
        ));
        // A valid URL with no explicit label stashes the pending add with no
        // user label (so `add_server` derives one) and opens the dest picker.
        dispatch_slash(&mut app, "mcp", "add https://mcp.atlassian.com/v1");
        assert_eq!(app.mode, Mode::Picker);
        assert_eq!(app.picker.as_ref().unwrap().kind, PickerKind::McpAddDest);
        let (label, url) = app.pending_mcp_add.as_ref().unwrap();
        assert_eq!(label, &None, "no explicit label → auto-derive");
        assert_eq!(url, "https://mcp.atlassian.com/v1");

        // An explicit label is preserved verbatim in the pending add.
        app.picker = None;
        dispatch_slash(&mut app, "mcp", "add https://mcp.example.com/v1 mylabel");
        assert_eq!(
            app.pending_mcp_add.as_ref().map(|(l, _)| l.clone()),
            Some(Some("mylabel".to_string()))
        );
    }

    #[test]
    fn slash_mcp_bare_opens_menu_and_add_row_prefills_composer() {
        let mut app = test_app(); // no servers configured
        dispatch_slash(&mut app, "mcp", "");
        assert_eq!(app.mode, Mode::Picker);
        assert_eq!(app.picker.as_ref().unwrap().kind, PickerKind::McpMenu);

        // With no servers, the only row is "add" — accepting it prefills the
        // composer with `/mcp add ` (the user then types the URL).
        accept_picker(&mut app);
        assert_eq!(app.mode, Mode::Normal);
        assert_eq!(app.input.lines().join("\n"), "/mcp add ");
    }

    #[test]
    fn toggling_approval_flips_state_and_stays_in_the_action_menu() {
        let mut app = test_app();
        app.settings.mcp_servers.push(McpServerSettings {
            server_label: "docs".into(),
            server_url: "https://mcp.example.com/docs".into(),
            server_description: None,
            require_approval: "always".into(),
            allowed_tools: None,
            auth: AuthKind::None,
            static_headers: std::collections::HashMap::new(),
            resolved_authorization: None,
        });

        // Open the per-server action menu, then accept the approval-toggle row.
        mcp_menu_select(&mut app, "docs");
        assert_eq!(
            app.picker.as_ref().unwrap().kind,
            PickerKind::McpServerActions
        );
        accept_picker(&mut app);

        // The setting flipped and we did NOT leave the menu — it was rebuilt so
        // the hint now offers the reverse toggle.
        assert_eq!(app.mode, Mode::Picker);
        let picker = app.picker.as_ref().expect("still in the action menu");
        assert_eq!(picker.kind, PickerKind::McpServerActions);
        assert_eq!(
            app.settings.mcp_servers[0].require_approval, "never",
            "approval toggled in-session"
        );
        assert_eq!(app.mcp_menu_server.as_deref(), Some("docs"));

        // A second accept toggles it back, still without leaving the menu.
        accept_picker(&mut app);
        assert_eq!(app.mode, Mode::Picker);
        assert_eq!(app.settings.mcp_servers[0].require_approval, "always");
    }

    #[tokio::test]
    async fn mcp_tokens_ready_without_login_dispatches_the_turn() {
        let mut app = test_app();
        app.pending_turn = Some(PendingTurn {
            user_text: Some("hi".into()),
        });
        app.mode = Mode::Streaming;
        // Apply a resolved bearer and no pending logins -> the turn dispatches.
        app.settings.mcp_servers.clear();
        finish_tokens_ready(
            &mut app,
            TokensReady {
                resolved: vec![],
                needs_login: vec![],
            },
        );
        assert_eq!(app.mode, Mode::Streaming, "dispatched, still streaming");
        assert!(app.pending_turn.is_none(), "pending turn consumed");
        assert!(app.cancel.is_some(), "a turn task was spawned");
    }

    #[test]
    fn completed_turn_commits_to_scrollback_and_status_renders() {
        let mut app = test_app();
        app.conv.push_user("hello there");
        app.conv.apply(ev(
            r#"{"type":"response.created","response":{"id":"r","status":"in_progress"}}"#,
        ));
        app.conv.apply(ev(
            r#"{"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"m","role":"assistant","content":[]}}"#,
        ));
        app.conv.apply(ev(
            r#"{"type":"response.output_text.delta","output_index":0,"delta":"Hi back"}"#,
        ));
        app.conv.apply(ev(
            r#"{"type":"response.completed","response":{"id":"r","status":"completed","usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}}}"#,
        ));

        // The turn ended (mode is Normal), so both blocks commit to scrollback.
        let committed = drain_commits(&mut app);
        assert!(committed.contains("hello there"), "user msg:\n{committed}");
        assert!(
            committed.contains("Hi back"),
            "assistant text:\n{committed}"
        );
        assert!(
            committed.contains('●'),
            "assistant turn marker:\n{committed}"
        );
        assert_eq!(app.flushed_blocks, 2, "both blocks consumed");
        assert!(app.live_tail_lines().is_none(), "nothing left in-flight");

        // The viewport still shows the model + running total in the status bar.
        let rendered = render_to_string(&app, 60, 12);
        assert!(
            rendered.contains("test/smoke"),
            "model in status:\n{rendered}"
        );
        assert!(rendered.contains("tok"), "compact token total:\n{rendered}");
    }

    #[test]
    fn streaming_text_commits_by_line_holding_back_the_partial_tail() {
        let mut app = test_app();
        app.conv.push_user("hi");
        app.conv.apply(ev(
            r#"{"type":"response.created","response":{"id":"r","status":"in_progress"}}"#,
        ));
        app.mode = Mode::Streaming;
        // Two complete lines plus a partial third, still streaming.
        app.conv.apply(ev(
            r#"{"type":"response.output_text.delta","output_index":0,"delta":"line one\nline two\npar"}"#,
        ));

        let committed = drain_commits(&mut app);
        // User message + the two settled lines commit; the partial line is held.
        assert!(committed.contains("hi"), "user msg:\n{committed}");
        assert!(committed.contains("line one"), "first line:\n{committed}");
        assert!(committed.contains("line two"), "second line:\n{committed}");
        assert!(
            !committed.contains("par"),
            "partial line held back:\n{committed}"
        );

        // The partial line is what the live tail shows.
        let tail = app.live_tail_lines().expect("partial line in flight");
        let tail_text: String = tail
            .iter()
            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
            .collect();
        assert!(
            tail_text.contains("par"),
            "live tail has partial:\n{tail_text}"
        );

        // When the turn ends, the held line commits too.
        app.mode = Mode::Normal;
        let rest = drain_commits(&mut app);
        assert!(rest.contains("par"), "partial committed on settle:\n{rest}");
        assert!(app.live_tail_lines().is_none());
    }

    #[test]
    fn shell_block_waits_for_its_output_before_committing() {
        // Hadrian emits the shell_call's `completed` BEFORE the separate
        // shell_call_output item. A commit pass landing in that window must not
        // freeze the block into scrollback empty — output arrives afterwards.
        let mut app = test_app();
        app.conv.push_user("run it");
        app.conv.apply(ev(
            r#"{"type":"response.created","response":{"id":"r","status":"in_progress"}}"#,
        ));
        app.mode = Mode::Streaming;
        app.conv.apply(ev(
            r#"{"type":"response.output_item.added","output_index":0,"item":{"type":"shell_call","id":"s1","call_id":"call_1","status":"in_progress","action":{"commands":["echo hi"]}}}"#,
        ));
        // shell_call reports completed, but its output hasn't arrived yet.
        app.conv.apply(ev(
            r#"{"type":"response.output_item.done","output_index":0,"item":{"type":"shell_call","id":"s1","call_id":"call_1","status":"completed","action":{"commands":["echo hi"]}}}"#,
        ));

        // The user message commits, but the shell block is held back.
        let early = drain_commits(&mut app);
        assert!(early.contains("run it"), "user msg commits:\n{early}");
        assert!(
            !early.contains("echo hi"),
            "shell block must NOT commit before its output:\n{early}"
        );
        assert_eq!(app.flushed_blocks, 1, "only the user msg consumed");

        // Now the output item arrives mid-stream; the block commits with it.
        app.conv.apply(ev(
            r#"{"type":"response.output_item.done","output_index":1,"item":{"type":"shell_call_output","id":"o1","call_id":"call_1","status":"completed","output":[{"stdout":"hi\n","stderr":""}]}}"#,
        ));
        let late = drain_commits(&mut app);
        assert!(late.contains("echo hi"), "command committed:\n{late}");
        assert!(late.contains("hi"), "output committed:\n{late}");
        assert_eq!(app.flushed_blocks, 2, "shell block now consumed");
    }

    #[tokio::test]
    async fn alt_enter_inserts_newline_instead_of_submitting() {
        let mut app = test_app();
        for ch in "hi".chars() {
            press(&mut app, KeyCode::Char(ch));
        }
        press_mods(&mut app, KeyCode::Enter, KeyModifiers::ALT);

        // A newline was inserted; no turn was dispatched.
        assert_eq!(app.input.lines().len(), 2, "alt+enter adds a line");
        assert_eq!(app.mode, Mode::Normal);
        assert!(app.cancel.is_none());
        assert!(app.conv.transcript.is_empty());

        // Plain Enter still submits.
        press(&mut app, KeyCode::Enter);
        assert_eq!(app.mode, Mode::Streaming);
        assert!(matches!(
            app.conv.transcript.first(),
            Some(RenderedBlock::UserMsg(t)) if t == "hi"
        ));
    }

    #[test]
    fn input_cursor_line_is_not_underlined() {
        // ratatui-textarea underlines the cursor line by default; we clear it.
        let app = test_app();
        assert_eq!(app.input.cursor_line_style(), Style::default());
        assert_eq!(fresh_input().cursor_line_style(), Style::default());
    }

    #[test]
    fn idle_composer_renders_prompt_arrow_and_horizontal_rules() {
        // Claude Code / Copilot–style composer: a `❯` prompt with full-width
        // rules above and below, not a boxed-in field with side borders.
        let app = test_app();
        let rendered = render_to_string(&app, 40, 4);
        assert!(rendered.contains('❯'), "prompt arrow:\n{rendered}");
        assert!(rendered.contains('─'), "horizontal rule:\n{rendered}");
        assert!(
            !rendered.contains('│'),
            "no vertical side borders:\n{rendered}"
        );
    }

    #[test]
    fn ctrl_t_toggles_detailed_stats() {
        let mut app = test_app();
        assert!(!app.show_stats);
        press_mods(&mut app, KeyCode::Char('t'), KeyModifiers::CONTROL);
        assert!(app.show_stats);
        press_mods(&mut app, KeyCode::Char('t'), KeyModifiers::CONTROL);
        assert!(!app.show_stats);
    }

    #[test]
    fn ctrl_u_deletes_to_start_of_line() {
        let mut app = test_app();
        for ch in "hello world".chars() {
            press(&mut app, KeyCode::Char(ch));
        }
        assert_eq!(app.input.lines().join("\n"), "hello world");
        press_mods(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
        // Cursor was at the end, so the whole line is discarded (shell-style).
        assert_eq!(app.input.lines().join("\n"), "");
    }

    #[test]
    fn viewport_height_grows_with_composer_unbounded() {
        let mut app = test_app();
        // Empty composer: one text row, inside a border, above the status row.
        assert_eq!(app.desired_viewport_height(), 1 + 2 + 1);

        // Each newline grows the composer by a row.
        for _ in 0..2 {
            press_mods(&mut app, KeyCode::Enter, KeyModifiers::ALT);
        }
        assert_eq!(app.input.lines().len(), 3);
        assert_eq!(app.desired_viewport_height(), 3 + 2 + 1);

        // No app-level cap: it keeps growing with the line count (the terminal
        // clamps the viewport to the screen height, not us).
        for _ in 0..20 {
            press_mods(&mut app, KeyCode::Enter, KeyModifiers::ALT);
        }
        assert_eq!(app.input.lines().len(), 23);
        assert_eq!(app.desired_viewport_height(), 23 + 2 + 1);

        // Streaming uses a fixed height regardless of the composer's contents.
        app.mode = Mode::Streaming;
        assert_eq!(app.desired_viewport_height(), STREAMING_ROWS + 1);
    }

    #[test]
    fn streaming_shows_live_token_progress() {
        let mut app = test_app();
        app.conv.push_user("hi");
        app.conv.apply(ev(
            r#"{"type":"response.created","response":{"id":"r","status":"in_progress"}}"#,
        ));
        app.conv.apply(ev(
            r#"{"type":"response.output_text.delta","output_index":0,"delta":"0123456789"}"#,
        ));
        app.mode = Mode::Streaming;
        app.turn_start = Some(Instant::now());

        let rendered = render_to_string(&app, 120, 12);
        // 10 streamed chars ≈ 3 tokens; shown as a live "↓ N tok" readout.
        assert!(
            rendered.contains('↓'),
            "live down-arrow readout:\n{rendered}"
        );
        assert!(rendered.contains("tok"), "live token count:\n{rendered}");
    }

    #[test]
    fn status_renders_cost_and_detailed_breakdown() {
        let mut app = test_app();
        app.conv.push_user("hi");
        app.conv.apply(ev(
            r#"{"type":"response.created","response":{"id":"r","status":"in_progress"}}"#,
        ));
        app.conv.apply(ev(r#"{"type":"response.completed","response":{"id":"r","status":"completed","usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"cost":0.0012}}}"#));

        let draw_text = |app: &App| render_to_string(app, 120, 12);

        // Compact view shows the cost.
        let compact = draw_text(&app);
        assert!(compact.contains("$0.0012"), "compact cost:\n{compact}");

        // Detailed view (Ctrl+T) shows the message count too.
        app.show_stats = true;
        let detailed = draw_text(&app);
        assert!(detailed.contains("msg"), "detailed messages:\n{detailed}");
        assert!(detailed.contains("$0.0012"), "detailed cost:\n{detailed}");
    }

    fn press(app: &mut App, code: KeyCode) {
        handle_key(app, KeyEvent::new(code, KeyModifiers::NONE));
    }

    fn press_mods(app: &mut App, code: KeyCode, mods: KeyModifiers) {
        handle_key(app, KeyEvent::new(code, mods));
    }

    fn type_str(app: &mut App, s: &str) {
        for ch in s.chars() {
            press(app, KeyCode::Char(ch));
        }
    }

    #[test]
    fn typing_slash_opens_and_filters_the_menu() {
        let mut app = test_app();
        type_str(&mut app, "/");
        assert!(app.slash.is_some(), "'/' opens the menu");

        type_str(&mut app, "re");
        let names: Vec<&str> = app
            .slash
            .as_ref()
            .unwrap()
            .items
            .iter()
            .map(|c| c.name)
            .collect();
        assert_eq!(names, vec!["resume", "rewind", "reasoning"], "/re filters");

        // A non-matching prefix closes the menu.
        type_str(&mut app, "zz");
        assert!(app.slash.is_none(), "no match -> menu closed");
    }

    #[test]
    fn accepting_a_toggle_command_flips_the_flag() {
        let mut app = test_app();
        assert!(!app.hide_reasoning);
        type_str(&mut app, "/reasoning");
        assert!(app.slash.is_some());
        press(&mut app, KeyCode::Enter); // accept the highlighted command
        assert!(app.hide_reasoning, "/reasoning toggled hide_reasoning");
        assert_eq!(app.mode, Mode::Normal, "no turn dispatched");
        assert!(
            matches!(app.conv.transcript.last(), Some(RenderedBlock::Notice(_))),
            "a notice confirms the toggle"
        );
    }

    #[test]
    fn clear_command_starts_a_fresh_conversation() {
        let mut app = test_app();
        // Run a turn so there's chain state, transcript and totals to clear.
        app.conv.push_user("hello");
        app.conv.apply(ev(
            r#"{"type":"response.created","response":{"id":"r1","status":"in_progress"}}"#,
        ));
        app.conv.apply(ev(
            r#"{"type":"response.output_text.delta","output_index":0,"delta":"hi"}"#,
        ));
        app.conv.apply(ev(
            r#"{"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"total_tokens":7}}}"#,
        ));
        assert_eq!(app.conv.last_response_id.as_deref(), Some("r1"));
        assert_eq!(app.conv.totals.messages, 1);

        dispatch_slash(&mut app, "clear", "");

        // Fresh chain, container and totals; the only transcript block is the
        // confirming notice.
        assert!(app.conv.last_response_id.is_none(), "chain pointer reset");
        assert!(app.conv.container_id.is_none(), "container reset");
        assert_eq!(app.conv.totals.messages, 0, "totals reset");
        assert_eq!(app.conv.transcript.len(), 1);
        assert!(matches!(
            app.conv.transcript.last(),
            Some(RenderedBlock::Notice(m)) if m.contains("new conversation")
        ));
        // The flush cursor is rewound and the screen-clear + banner are queued.
        assert_eq!(app.flushed_blocks, 0);
        assert!(app.pending_clear && app.pending_welcome);
        assert_eq!(app.mode, Mode::Normal);
    }

    #[test]
    fn clear_command_is_refused_while_streaming() {
        let mut app = test_app();
        app.conv.push_user("hi");
        app.mode = Mode::Streaming;
        dispatch_slash(&mut app, "clear", "");
        // Still streaming, conversation untouched apart from a refusal notice.
        assert_eq!(app.mode, Mode::Streaming);
        assert!(!app.pending_clear);
        assert!(matches!(
            app.conv.transcript.last(),
            Some(RenderedBlock::Notice(m)) if m.contains("finish the current turn")
        ));
    }

    #[test]
    fn slash_model_with_arg_changes_model_locally() {
        let mut app = test_app();
        app.input = input_with_text("/model gpt-x");
        // A space closed the menu; Enter submits and the leading `/` routes it.
        assert!(slash::filter("/model gpt-x").is_none());
        submit(&mut app);
        assert_eq!(app.settings.model, "gpt-x");
        assert_eq!(app.mode, Mode::Normal, "command, not a turn");
        assert!(
            app.conv
                .transcript
                .iter()
                .any(|b| matches!(b, RenderedBlock::Notice(m) if m.contains("model → gpt-x"))),
            "model-change notice: {:?}",
            app.conv.transcript
        );
    }

    #[test]
    fn unknown_slash_command_is_not_sent_to_the_server() {
        let mut app = test_app();
        app.input = input_with_text("/bogus");
        submit(&mut app);
        assert_eq!(app.mode, Mode::Normal, "not dispatched as a turn");
        assert!(app.cancel.is_none());
        assert!(matches!(
            app.conv.transcript.last(),
            Some(RenderedBlock::Notice(m)) if m.contains("unknown command")
        ));
    }

    #[test]
    fn up_down_cycle_prompt_history() {
        let mut app = test_app();
        app.history = vec!["first".into(), "second".into()];

        press(&mut app, KeyCode::Up); // newest
        assert_eq!(app.input.lines().join("\n"), "second");
        press(&mut app, KeyCode::Up); // older
        assert_eq!(app.input.lines().join("\n"), "first");
        press(&mut app, KeyCode::Up); // already oldest -> unchanged
        assert_eq!(app.input.lines().join("\n"), "first");
        press(&mut app, KeyCode::Down); // newer
        assert_eq!(app.input.lines().join("\n"), "second");
        press(&mut app, KeyCode::Down); // past the end -> restored (empty) draft
        assert_eq!(app.input.lines().join("\n"), "");
    }

    #[test]
    fn effort_picker_filters_and_applies() {
        let mut app = test_app();
        dispatch_slash(&mut app, "effort", "");
        assert_eq!(app.mode, Mode::Picker);
        assert_eq!(app.picker.as_ref().unwrap().kind, PickerKind::Effort);

        // Type to filter down to "high", then choose it.
        type_str(&mut app, "hi");
        assert_eq!(
            app.picker.as_ref().unwrap().selected_item().unwrap().label,
            "high"
        );
        press(&mut app, KeyCode::Enter);

        assert_eq!(app.mode, Mode::Normal);
        assert_eq!(
            app.settings
                .reasoning
                .as_ref()
                .and_then(|r| r.effort.clone()),
            Some("high".to_string())
        );
    }

    #[test]
    fn model_picker_populates_then_filters_and_applies() {
        let mut app = test_app();
        // Simulate the async open + load (avoids a real network fetch).
        let mut picker = Picker::new(PickerKind::Model, Vec::new());
        picker.loading = true;
        app.picker = Some(picker);
        app.mode = Mode::Picker;
        finish_models_loaded(
            &mut app,
            Ok(vec![
                ModelInfo::new("x/alpha"),
                ModelInfo::new("x/beta"),
                ModelInfo::new("y/gamma"),
            ]),
        );
        assert!(!app.picker.as_ref().unwrap().loading);

        // Filter to the single 'y/' model and pick it.
        type_str(&mut app, "y/");
        assert_eq!(app.picker.as_ref().unwrap().visible.len(), 1);
        press(&mut app, KeyCode::Enter);

        assert_eq!(app.settings.model, "y/gamma");
        assert!(app
            .conv
            .transcript
            .iter()
            .any(|b| matches!(b, RenderedBlock::Notice(m) if m.contains("model → y/gamma"))));
        // Picking a model chains straight into the effort picker.
        assert_eq!(app.mode, Mode::Picker);
        assert_eq!(app.picker.as_ref().unwrap().kind, PickerKind::Effort);
    }

    #[test]
    fn model_picker_accepts_a_name_not_in_the_list() {
        let mut app = test_app();
        let mut picker = Picker::new(PickerKind::Model, Vec::new());
        picker.loading = true;
        app.picker = Some(picker);
        app.mode = Mode::Picker;
        finish_models_loaded(
            &mut app,
            Ok(vec![ModelInfo::new("x/alpha"), ModelInfo::new("x/beta")]),
        );

        // Type a model that doesn't match any list entry — the freeform
        // "use …" row is offered and selected.
        type_str(&mut app, "custom/model-z");
        assert_eq!(
            app.picker.as_ref().unwrap().visible.len(),
            0,
            "no list match"
        );
        press(&mut app, KeyCode::Enter);

        assert_eq!(app.settings.model, "custom/model-z");
        // Still chains into the effort picker.
        assert_eq!(app.mode, Mode::Picker);
        assert_eq!(app.picker.as_ref().unwrap().kind, PickerKind::Effort);
    }

    #[test]
    fn model_picker_stays_open_on_list_error_for_manual_entry() {
        let mut app = test_app();
        let mut picker = Picker::new(PickerKind::Model, Vec::new());
        picker.loading = true;
        app.picker = Some(picker);
        app.mode = Mode::Picker;

        // The list fetch fails (e.g. the server is unreachable).
        finish_models_loaded(&mut app, Err("http request failed".into()));

        // The picker stays open (not closed to a notice) with the error recorded,
        // so the user can still type a model name and pick it.
        assert_eq!(app.mode, Mode::Picker);
        let p = app.picker.as_ref().unwrap();
        assert!(!p.loading);
        assert!(p.error.as_deref().unwrap().contains("http request failed"));

        type_str(&mut app, "openrouter/x");
        press(&mut app, KeyCode::Enter);

        assert_eq!(app.settings.model, "openrouter/x");
        // And still chains into the effort picker.
        assert_eq!(app.mode, Mode::Picker);
        assert_eq!(app.picker.as_ref().unwrap().kind, PickerKind::Effort);
    }

    #[test]
    fn model_picker_accepts_a_pasted_name() {
        let mut app = test_app();
        let mut picker = Picker::new(PickerKind::Model, Vec::new());
        picker.loading = true;
        app.picker = Some(picker);
        app.mode = Mode::Picker;
        finish_models_loaded(&mut app, Err("offline".into()));

        // Paste a model name into the (modal) picker — it feeds the filter and
        // offers the freeform row, with a trailing newline stripped.
        apply_event(
            &mut app,
            AppEvent::Input(CtEvent::Paste("anthropic/claude-x\n".into())),
        );
        assert_eq!(app.picker.as_ref().unwrap().filter, "anthropic/claude-x");

        press(&mut app, KeyCode::Enter);
        assert_eq!(app.settings.model, "anthropic/claude-x");
    }

    #[test]
    fn rewind_picker_truncates_and_rearms_the_flush_cursor() {
        let mut app = test_app();
        // Two completed turns.
        for k in 1..=2 {
            app.conv.push_user(format!("u{k}"));
            let _ = app.conv.build_request(&app.settings, Some(format!("u{k}")));
            app.conv.apply(ev(&format!(
                r#"{{"type":"response.created","response":{{"id":"r{k}","status":"in_progress"}}}}"#
            )));
            app.conv.apply(ev(&format!(
                r#"{{"type":"response.output_text.delta","output_index":0,"delta":"a{k}"}}"#
            )));
            app.conv.apply(ev(&format!(
                r#"{{"type":"response.completed","response":{{"id":"r{k}","status":"completed"}}}}"#
            )));
        }
        assert_eq!(app.conv.transcript.len(), 4);

        // Open the rewind picker and choose the first turn.
        dispatch_slash(&mut app, "rewind", "");
        assert_eq!(app.mode, Mode::Picker);
        assert_eq!(app.picker.as_ref().unwrap().items.len(), 2);
        app.picker.as_mut().unwrap().selected = 0;
        accept_picker(&mut app);

        // Back to normal: transcript is just the rewind marker, the chain pointer
        // is reset to before turn 1 (None), and the flush cursor points at the
        // marker so only it commits.
        assert_eq!(app.mode, Mode::Normal);
        assert!(app.picker.is_none());
        assert_eq!(app.conv.transcript.len(), 1);
        assert!(matches!(
            app.conv.transcript.last(),
            Some(RenderedBlock::Notice(_))
        ));
        assert!(app.conv.last_response_id.is_none());
        assert_eq!(app.flushed_blocks, 0);
    }

    #[tokio::test]
    async fn typing_then_enter_dispatches_a_turn() {
        let mut app = test_app();
        for ch in "hi".chars() {
            press(&mut app, KeyCode::Char(ch));
        }
        assert_eq!(app.input.lines().join(""), "hi");

        press(&mut app, KeyCode::Enter);

        // The user message is recorded, the turn is dispatched, input cleared.
        assert!(matches!(
            app.conv.transcript.first(),
            Some(RenderedBlock::UserMsg(t)) if t == "hi"
        ));
        assert_eq!(app.mode, Mode::Streaming);
        assert!(app.cancel.is_some());
        assert_eq!(app.input.lines().join(""), "");

        // While streaming, Enter must not dispatch another turn.
        press(&mut app, KeyCode::Enter);
        assert_eq!(
            app.conv
                .transcript
                .iter()
                .filter(|b| matches!(b, RenderedBlock::UserMsg(_)))
                .count(),
            1
        );
    }

    #[tokio::test]
    async fn approval_modal_flow_dispatches_continuation() {
        let mut app = test_app();
        // A turn that ends with a parked MCP approval request.
        app.conv.apply(ev(
            r#"{"type":"response.created","response":{"id":"r1","status":"in_progress"}}"#,
        ));
        app.conv.apply(ev(
            r#"{"type":"response.output_item.done","output_index":0,"item":{"type":"mcp_approval_request","id":"mcpr_1","server_label":"gh","name":"create_issue","arguments":"{\"title\":\"x\"}"}}"#,
        ));
        apply_event(
            &mut app,
            AppEvent::Stream(ev(
                r#"{"type":"response.completed","response":{"id":"r1","status":"completed"}}"#,
            )),
        );
        // Stream end → approval mode.
        apply_event(&mut app, AppEvent::StreamDone);
        assert_eq!(app.mode, Mode::AwaitingApproval);

        // The modal renders the pending call.
        let rendered = render_to_string(&app, 80, 20);
        assert!(rendered.contains("approval"), "modal shown:\n{rendered}");

        // Approving dispatches a continuation turn.
        press(&mut app, KeyCode::Char('a'));
        assert_eq!(app.mode, Mode::Streaming);
        assert!(!app.conv.has_undecided_approvals());
        assert!(app.cancel.is_some());
    }

    #[test]
    fn ctrl_c_needs_two_presses_to_quit_when_idle() {
        let mut app = test_app();
        // First press arms the "press again to exit" prime without quitting.
        press_mods(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert!(!app.should_quit, "one press only arms");
        assert!(app.quit_hint().is_some(), "hint shown after first press");
        // A second Ctrl-C (in the window) quits.
        press_mods(&mut app, KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert!(app.should_quit, "second press quits");
    }

    #[test]
    fn a_different_key_clears_the_quit_prime() {
        let mut app = test_app();
        press_mods(&mut app, KeyCode::Char('d'), KeyModifiers::CONTROL);
        assert!(app.quit_hint().is_some());
        // Typing anything else disarms it…
        press(&mut app, KeyCode::Char('x'));
        assert!(app.quit_hint().is_none(), "any other key disarms");
        // …so the next Ctrl-D only re-arms rather than quitting.
        press_mods(&mut app, KeyCode::Char('d'), KeyModifiers::CONTROL);
        assert!(!app.should_quit);
        assert!(app.quit_hint().is_some());
    }

    #[tokio::test]
    async fn enter_accepts_the_bootstrap_prompt() {
        let mut app = bootstrap_app();
        press(&mut app, KeyCode::Enter);
        // Enter behaves like 'y': the upload begins.
        assert_eq!(app.bootstrap, Bootstrap::Uploading);
    }

    #[tokio::test]
    async fn messages_typed_while_streaming_are_queued_then_drained() {
        let mut app = test_app();
        // test_app loads this project dir's real prompt history from disk; clear
        // it so the history assertion below is deterministic.
        app.history.clear();
        // Start a turn.
        type_str(&mut app, "first");
        press(&mut app, KeyCode::Enter);
        assert_eq!(app.mode, Mode::Streaming);

        // Typing + Enter while streaming queues rather than dispatching.
        type_str(&mut app, "second");
        press(&mut app, KeyCode::Enter);
        assert_eq!(app.queued_len(), 1, "queued, not sent");
        assert_eq!(app.mode, Mode::Streaming);
        assert_eq!(
            app.conv
                .transcript
                .iter()
                .filter(|b| matches!(b, RenderedBlock::UserMsg(_)))
                .count(),
            1,
            "only the first message is a turn so far"
        );

        // Completing the turn drains the queued message as the next turn.
        apply_event(&mut app, AppEvent::StreamDone);
        assert_eq!(app.mode, Mode::Streaming, "queued message dispatched");
        assert_eq!(app.queued_len(), 0, "queue drained");
        assert!(
            matches!(app.conv.transcript.last(), Some(RenderedBlock::UserMsg(t)) if t == "second"),
            "queued message became a turn: {:?}",
            app.conv.transcript.last()
        );
        // The queued message is recorded in prompt history exactly once (not
        // again on drain): "first" + "second", no duplicate.
        assert_eq!(app.history, vec!["first".to_string(), "second".to_string()]);
    }

    #[tokio::test]
    async fn a_failed_turn_discards_queued_messages() {
        let mut app = test_app();
        type_str(&mut app, "first");
        press(&mut app, KeyCode::Enter);
        type_str(&mut app, "second");
        press(&mut app, KeyCode::Enter);
        assert_eq!(app.queued_len(), 1);

        // A stream error clears the queue rather than replaying it later.
        apply_event(&mut app, AppEvent::StreamError("boom".into()));
        assert_eq!(app.mode, Mode::Normal);
        assert_eq!(app.queued_len(), 0, "errored turn discards the queue");
    }

    #[tokio::test]
    async fn large_paste_collapses_to_a_placeholder_and_expands_on_send() {
        let mut app = test_app();
        let pasted = (1..=8)
            .map(|i| format!("line {i}"))
            .collect::<Vec<_>>()
            .join("\n");
        handle_paste(&mut app, pasted.clone());
        // The composer shows a placeholder, not the raw text.
        let shown = app.input.lines().join("\n");
        assert!(
            shown.contains("[Pasted text #1 +8 lines]"),
            "placeholder: {shown}"
        );
        assert!(!shown.contains("line 5"), "raw text hidden: {shown}");
        // Submitting expands it back to the full text.
        press(&mut app, KeyCode::Enter);
        assert!(
            matches!(app.conv.transcript.first(), Some(RenderedBlock::UserMsg(t)) if t.contains("line 8")),
            "expanded on send: {:?}",
            app.conv.transcript.first()
        );

        // A small paste inserts inline (no placeholder).
        let mut app = test_app();
        handle_paste(&mut app, "just one line".into());
        assert_eq!(app.input.lines().join("\n"), "just one line");
    }

    #[test]
    fn paste_normalizes_cr_line_endings_into_real_lines() {
        // Terminals deliver bracketed-paste newlines as CR/CRLF; a small multi-line
        // paste must become real composer lines, not one mashed-together line.
        let mut app = test_app();
        handle_paste(&mut app, "x\r\ny\rz".into());
        assert_eq!(
            app.input.lines(),
            &["x", "y", "z"],
            "CR/CRLF split into lines"
        );

        // And the CR-delimited line count trips the collapse threshold (≥5 lines).
        let mut app = test_app();
        handle_paste(&mut app, "a\rbc\rdef\rghi\rjkl\rmnop\rqrs".into());
        let shown = app.input.lines().join("\n");
        assert!(
            shown.contains("[Pasted text #1 +7 lines]"),
            "collapsed: {shown}"
        );
        // Expanding (on send / in $EDITOR) restores the lines with LF separators.
        assert!(
            app.expanded_input().contains("a\nbc\ndef"),
            "expands to LF text"
        );
    }

    #[test]
    fn ctrl_l_requests_a_screen_clear() {
        let mut app = test_app();
        press_mods(&mut app, KeyCode::Char('l'), KeyModifiers::CONTROL);
        assert!(app.pending_clear, "Ctrl-L flags a screen clear");
    }

    fn shell_settings(skill_id: Option<String>) -> Settings {
        Settings {
            base_url: "http://localhost:8080/api".into(),
            api_key: None,
            model: "test/smoke".into(),
            instructions: None,
            system_prompt: None,
            tools: vec![ToolKind::Shell],
            vector_store_ids: vec![],
            mcp_servers: vec![],
            compaction: None,
            container: ContainerConfig::default(),
            reasoning: None,
            skill_id,
            log_dir: None,
            redact_logs: true,
        }
    }

    #[test]
    fn bootstrap_offered_only_when_shell_enabled_and_uncached() {
        // Shell on, no cached skill, default preference -> offer.
        assert_eq!(
            App::initial_bootstrap(&shell_settings(None), None),
            Bootstrap::Prompt
        );
        // Already cached -> no offer.
        assert_eq!(
            App::initial_bootstrap(&shell_settings(Some("skill_x".into())), None),
            Bootstrap::Inactive
        );
        // `--no-bootstrap` suppresses even when it would otherwise offer.
        assert_eq!(
            App::initial_bootstrap(&shell_settings(None), Some(false)),
            Bootstrap::Inactive
        );
        // Shell disabled -> nothing to mount into, no offer.
        let mut no_shell = shell_settings(None);
        no_shell.tools = vec![ToolKind::WebSearch];
        assert_eq!(App::initial_bootstrap(&no_shell, None), Bootstrap::Inactive);
    }

    fn bootstrap_app() -> App {
        let settings = shell_settings(None);
        let client = Client::new(settings.base_url.clone(), None).unwrap();
        let (tx, _rx) = mpsc::unbounded_channel();
        let mut app = App::new(
            settings,
            client,
            tx,
            None,
            Overrides::default(),
            None,
            false,
            None,
            None,
        );
        // run() flips into the prompt mode after the banner; do it by hand here.
        assert_eq!(app.bootstrap, Bootstrap::Prompt);
        app.mode = Mode::AwaitingBootstrap;
        app
    }

    #[test]
    fn bootstrap_decline_returns_to_normal_input() {
        let mut app = bootstrap_app();
        press(&mut app, KeyCode::Char('n'));
        assert_eq!(app.mode, Mode::Normal);
        assert_eq!(app.bootstrap, Bootstrap::Inactive);
        assert!(app.settings.skill_id.is_none(), "declining mounts nothing");
        // The prompt panel renders the offer while awaiting.
        let shown = bootstrap_app();
        let rendered = render_to_string(&shown, 70, 12);
        assert!(
            rendered.contains("file editing"),
            "panel title:\n{rendered}"
        );
    }

    #[test]
    fn bootstrap_success_activates_skill_and_clears() {
        let mut app = bootstrap_app();
        finish_bootstrap(&mut app, Ok("skill_edit_123".into()));
        assert_eq!(app.mode, Mode::Normal);
        assert_eq!(app.bootstrap, Bootstrap::Inactive);
        assert_eq!(app.settings.skill_id.as_deref(), Some("skill_edit_123"));
        // No config path in tests -> nothing persisted, and no error surfaced.
        assert!(
            !app.conv
                .transcript
                .iter()
                .any(|b| matches!(b, RenderedBlock::Error { .. })),
            "success must not surface an error"
        );
    }

    #[test]
    fn bootstrap_failure_surfaces_error_and_continues() {
        let mut app = bootstrap_app();
        finish_bootstrap(&mut app, Err("connection refused".into()));
        assert_eq!(app.mode, Mode::Normal);
        assert_eq!(app.bootstrap, Bootstrap::Inactive);
        assert!(
            app.settings.skill_id.is_none(),
            "failed upload mounts nothing"
        );
        assert!(
            matches!(
                app.conv.transcript.last(),
                Some(RenderedBlock::Error { message, .. }) if message.contains("connection refused")
            ),
            "failure reason surfaced: {:?}",
            app.conv.transcript.last()
        );
    }

    /// Paint the bottom viewport into a fresh `w`×`h` buffer and flatten it to
    /// text. Mirrors what the vendored terminal hands `render::draw` each frame.
    fn render_to_string(app: &App, w: u16, h: u16) -> String {
        let area = Rect::new(0, 0, w, h);
        let mut buf = Buffer::empty(area);
        render::draw(&mut buf, area, app);
        buffer_text(&buf)
    }

    /// Flatten a ratatui buffer to a newline-joined string for assertions.
    fn buffer_text(buf: &ratatui::buffer::Buffer) -> String {
        let area = buf.area();
        (0..area.height)
            .map(|y| {
                (0..area.width)
                    .map(|x| buf[(x, y)].symbol())
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }
}
