//! The pure reducer: `fn update(State, Msg) -> (State, Vec<Cmd>)`.
//!
//! Four rules — every future change to this file is checked against them:
//!
//!   1. **No I/O, no async.** The function is `sync`, never awaits,
//!      never opens files. All side-effects are returned as `Cmd`.
//!   2. **No wildcards.** The `match msg` is exhaustive. Adding a new
//!      `Msg` variant is a compile error until every arm is handled.
//!   3. **Stale-filter first.** Any `Msg` carrying a `TurnId` that
//!      doesn't match `state.turn.id()` is dropped without state
//!      change. This is the architectural safeguard that the previous
//!      `check_interrupt` polling tried to enforce by convention.
//!   4. **Cancellation is explicit.** The only way to abort in-flight
//!      work is `Cmd::CancelScope(turn)`. No `handle.abort()` anywhere.
//!
//! Internal split:
//!
//!   - `update_step(State, Msg) -> (State, Vec<Cmd>)` — a single
//!     reducer call. Pure, deterministic, exhaustive match.
//!   - `update(State, Msg) -> (State, Vec<Cmd>)` — runs a step,
//!     then drains `state.ui.pending_msgs` in a bounded loop so
//!     handlers can enqueue follow-up events (Enter-on-slash,
//!     queued-message auto-submit) without self-invoking the
//!     reducer.

use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE};
use crate::models::{ChatMessage, MessageRole};
use crate::prompts::get_system_prompt;
use crate::runtime::TaskStatus;

use super::cmd::{ChatRequest, Cmd};
use super::compaction::{
    CompactionArchive, CompactionPolicy, CompactionRequest, CompactionResult, CompactionTrigger,
    compaction_receipt, context_exceeds_hard_limit, should_auto_compact,
};
use super::ids::TurnId;
use super::msg::{KeyCode, KeyMods, Msg, Paste, SlashCmd};
use super::state::{
    GenPhase, McpServerEntry, McpServerStatus, State, StatusKind, StatusLine, TokenUsageTotals,
    ToolOutcome, TurnState, UiMode,
};
use super::transition::{
    action_display_for, commit_assistant_message, fill_outcome, start_generating,
    tool_result_messages, try_complete_outcomes,
};
use super::{COMMAND_GROUPS, COMMAND_REGISTRY};

/// Cap on how many queued follow-up messages get drained per
/// external `update()` call. Arms typically enqueue zero or one
/// follow-up; this cap catches runaway loops from future arms that
/// might enqueue unboundedly.
const MAX_PENDING_DRAIN: usize = 16;

/// The public reducer entry point. Runs one `update_step` for the
/// incoming `msg`, then drains any follow-up `Msg`s the handler
/// pushed onto `state.ui.pending_msgs`. All emitted `Cmd`s coalesce
/// into the returned vector.
pub fn update(mut state: State, msg: Msg) -> (State, Vec<Cmd>) {
    let (new_state, mut cmds) = update_step(state, msg);
    state = new_state;
    let mut depth = 0usize;
    while let Some(follow) = state.ui.pending_msgs.pop_front() {
        if depth >= MAX_PENDING_DRAIN {
            tracing::warn!(
                max = MAX_PENDING_DRAIN,
                remaining = state.ui.pending_msgs.len(),
                "reducer: pending_msgs drain cap hit — follow-ups dropped this tick"
            );
            state.ui.pending_msgs.clear();
            break;
        }
        let (s, c) = update_step(state, follow);
        state = s;
        cmds.extend(c);
        depth += 1;
    }
    (state, cmds)
}

/// Single-step reducer: one `Msg` in, new `State` + `Cmd`s out.
/// Callers interested in re-entry (queued follow-up messages) go
/// through `update()`; this function returns after a single pass.
pub fn update_step(mut state: State, msg: Msg) -> (State, Vec<Cmd>) {
    let mut cmds = Vec::new();

    // Stale-event filter: if this is an effect result for a turn we're
    // no longer on, drop without state change. `turn_id()` returns
    // `None` for non-turn-scoped messages, which short-circuits the
    // check (Some(id) != None).
    if let Some(event_turn) = msg.turn_id()
        && !state.turn.accepts(event_turn)
    {
        tracing::trace!(
            event_turn = %event_turn,
            active_turn = ?state.turn.id(),
            kind = ?msg.kind(),
            "reducer: dropped stale message"
        );
        return (state, cmds);
    }

    match msg {
        // ── User intent ─────────────────────────────────────────────
        Msg::Key(key) => {
            handle_key(&mut state, &mut cmds, key.code, key.modifiers);
        },
        Msg::Paste(paste) => {
            handle_paste(&mut state, &mut cmds, paste);
        },
        Msg::SubmitPrompt {
            text,
            attachment_ids,
        } => {
            handle_submit_prompt(&mut state, &mut cmds, text, &attachment_ids);
        },
        Msg::Slash(cmd) => {
            handle_slash(&mut state, &mut cmds, cmd);
        },
        Msg::CancelTurn => {
            handle_cancel_turn(&mut state, &mut cmds);
        },
        Msg::ConfirmAccepted => {
            handle_confirm_accepted(&mut state, &mut cmds);
        },
        Msg::ConfirmDeclined => {
            state.confirm = None;
        },
        Msg::Quit => {
            request_exit(&mut state, &mut cmds);
        },
        Msg::RuntimeSignal(signal) => {
            state.runtime.record_signal(signal);
            request_exit(&mut state, &mut cmds);
        },

        // ── Streaming ───────────────────────────────────────────────
        Msg::StreamText { turn, chunk } => {
            if let TurnState::Generating {
                id,
                partial_text,
                phase,
                tokens,
                ..
            } = &mut state.turn
                && *id == turn
            {
                partial_text.push_str(&chunk);
                *phase = GenPhase::Streaming;
                // Rough token estimate — actual count comes in `Done`.
                *tokens = partial_text.len() / 4;
            }
        },
        Msg::StreamReasoning { turn, chunk } => {
            if let TurnState::Generating {
                id,
                partial_reasoning,
                phase,
                thinking_signature,
                ..
            } = &mut state.turn
                && *id == turn
            {
                partial_reasoning.push_str(&chunk.text);
                *phase = GenPhase::Thinking;
                if let Some(sig) = chunk.signature {
                    *thinking_signature = Some(sig);
                }
            }
        },
        Msg::StreamToolCall { turn, call } => {
            handle_stream_tool_call(&mut state, turn, call);
        },
        Msg::ContextUsageEstimated { turn, snapshot } => {
            if state.turn.accepts(turn) {
                state.session.context_usage = Some(snapshot);
            }
        },
        Msg::CompactionFinished { turn, result } => {
            handle_compaction_finished(&mut state, &mut cmds, turn, result);
        },
        Msg::CompactionFailed {
            turn,
            trigger,
            message,
            kind,
        } => {
            handle_compaction_failed(&mut state, turn, trigger, message, kind);
        },
        Msg::StreamDone {
            turn,
            usage,
            thinking_signature,
        } => {
            handle_stream_done(&mut state, &mut cmds, turn, usage, thinking_signature);
        },
        Msg::UpstreamError { turn, error } => {
            handle_upstream_error(&mut state, turn, error);
        },
        Msg::TurnCancelled(turn) => {
            handle_turn_cancelled(&mut state, turn);
        },

        // ── Tools ───────────────────────────────────────────────────
        Msg::ToolStarted {
            turn: _,
            call_id: _,
        } => {
            // Informational — render layer derives spinner state from
            // `outcomes[i].is_none()`, so no state change needed yet.
        },
        Msg::ToolProgress {
            turn,
            call_id: _,
            event,
        } => {
            handle_tool_progress(&mut state, &mut cmds, turn, event);
        },
        Msg::ToolFinished {
            turn,
            call_id,
            outcome,
        } => {
            handle_tool_finished(&mut state, &mut cmds, turn, call_id, outcome);
        },
        Msg::ApprovalRequested {
            turn,
            call_id,
            tool,
            risk,
            kind,
            prompt,
            allowlist_scope,
        } => {
            // Enqueue a modal; the parked tool task waits until the user
            // answers (key handler → Cmd::ResolveApproval). FIFO so multiple
            // gated tools in one turn are shown one at a time.
            state
                .pending_approval
                .push_back(super::state::PendingApproval {
                    turn,
                    call_id,
                    tool,
                    risk,
                    kind,
                    prompt,
                    allowlist_scope,
                    selected_option: 0,
                });
        },

        // ── MCP ─────────────────────────────────────────────────────
        // F5: upsert semantics. State::new seeds entries for configured
        // servers in `Starting` status, so these handlers normally find
        // an existing entry to update. But a server discovered at
        // runtime (hypothetical future path) should still land in the
        // map — insert rather than silently drop.
        Msg::McpServerReady { name, tools } => {
            state
                .mcp
                .servers
                .entry(name)
                .and_modify(|e| {
                    e.status = McpServerStatus::Ready;
                    e.tools = tools.clone();
                })
                .or_insert_with(|| McpServerEntry {
                    config: crate::app::McpServerConfig {
                        command: String::new(),
                        args: Vec::new(),
                        env: std::collections::HashMap::new(),
                    },
                    status: McpServerStatus::Ready,
                    tools,
                });
        },
        Msg::McpServerErrored { name, reason } => {
            let status = McpServerStatus::Errored {
                reason: reason.clone(),
            };
            state
                .mcp
                .servers
                .entry(name.clone())
                .and_modify(|e| e.status = status.clone())
                .or_insert_with(|| McpServerEntry {
                    config: crate::app::McpServerConfig {
                        command: String::new(),
                        args: Vec::new(),
                        env: std::collections::HashMap::new(),
                    },
                    status,
                    tools: Vec::new(),
                });
            state.status = Some(StatusLine {
                text: format!("MCP server {} errored: {}", name, reason),
                kind: StatusKind::Error,
                shown_at: std::time::SystemTime::now(),
            });
        },
        Msg::McpServerStopped { name } => {
            if let Some(entry) = state.mcp.servers.get_mut(&name) {
                entry.status = McpServerStatus::Stopped;
            }
        },

        // ── Persistence / misc ─────────────────────────────────────
        Msg::InstructionsChanged(loaded) => {
            state.instructions = loaded;
        },
        Msg::MemoryChanged(loaded) => {
            state.memory = loaded;
        },
        Msg::SessionSaved => {
            // Silent. Reducer already committed; save is just durability.
        },
        Msg::ConversationLoaded(history) => {
            state.session.conversation = history;
            state.turn = TurnState::Idle;
            state.ui.mode = UiMode::EditingInput;
            emit_title_if_changed(&mut state, &mut cmds);
        },
        Msg::ConversationsListed(candidates) => {
            if let UiMode::ConversationList { .. } = state.ui.mode {
                state.ui.mode = UiMode::ConversationList {
                    candidates,
                    cursor: 0,
                };
            }
            // If the user already navigated away (Esc before the
            // list landed), the event silently drops.
        },
        Msg::RuntimeTasksListed(tasks) => {
            state
                .session
                .append(ChatMessage::system(tasks_text(&tasks)));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        Msg::RuntimeTaskLoaded { task, events } => {
            state.session.append(ChatMessage::system(task_detail_text(
                task.as_ref(),
                &events,
            )));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        Msg::RuntimeProcessesListed(processes) => {
            state
                .session
                .append(ChatMessage::system(processes_text(&processes)));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        Msg::RuntimeText(text) => {
            state.session.append(ChatMessage::system(text));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        Msg::RuntimeApprovalsListed(approvals) => {
            state
                .session
                .append(ChatMessage::system(approvals_text(&approvals)));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        Msg::RuntimeCheckpointsListed(checkpoints) => {
            state
                .session
                .append(ChatMessage::system(checkpoints_text(&checkpoints)));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        Msg::RuntimePluginsListed(plugins) => {
            state
                .session
                .append(ChatMessage::system(plugins_text(&plugins)));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        Msg::ModelPullFinished { model } => {
            state.status = Some(StatusLine {
                text: format!("Pulled {}", model),
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::DismissStatusAfter { ms: 2_000 });
        },
        Msg::ModelPullProgress(line) => {
            state.status = Some(StatusLine {
                text: format!("ollama: {}", line),
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            // Don't dismiss — the next progress line will overwrite
            // this one; the final ModelPullFinished dismisses.
        },

        // ── Housekeeping ────────────────────────────────────────────
        Msg::Tick => {
            // Nothing stateful yet; render uses `SystemTime::now()`
            // directly for elapsed-time display.
        },
        Msg::StatusDismiss => {
            state.status = None;
        },
        Msg::Resize { .. } => {
            // Render layer recomputes layout from the new area — no
            // reducer state depends on raw terminal dimensions.
        },
        Msg::MouseScroll { delta } => {
            // F13: accumulate into a counter. Render layer diffs
            // against its last-seen value and applies the resulting
            // delta to ChatState. `saturating_add` never overflows.
            state.ui.mouse_scroll_accum = state.ui.mouse_scroll_accum.saturating_add(delta as i32);
        },
        Msg::TransientStatus {
            text,
            kind,
            dismiss_ms,
        } => {
            // F14: generic async-feedback banner. An effect handler
            // that wants to say "clipboard is empty", "config saved",
            // etc. ships its one-liner here; we render it via the
            // existing StatusBannerWidget and self-dismiss.
            state.status = Some(StatusLine {
                text,
                kind,
                shown_at: std::time::SystemTime::now(),
            });
            if dismiss_ms > 0 {
                cmds.push(Cmd::DismissStatusAfter { ms: dismiss_ms });
            }
        },
        Msg::OpenImageAt {
            message_index,
            image_index,
        } => {
            handle_open_image_at(&mut state, &mut cmds, message_index, image_index);
        },
    }

    (state, cmds)
}

/// Emit `Cmd::SetTerminalTitle` iff the derived title changed since
/// the last emission. Called from arms that actually mutate
/// `state.session.conversation.title` (SubmitPrompt, ConversationLoaded,
/// ConfirmAccepted → ClearConversation) — never at the tail of every
/// update() so `Tick`/resize/etc. stay free.
fn emit_title_if_changed(state: &mut State, cmds: &mut Vec<Cmd>) {
    let current = state.session.conversation.title.clone();
    if state.ui.last_title_dispatched.as_deref() != Some(current.as_str()) {
        cmds.push(Cmd::SetTerminalTitle(format!("mermaid - {}", current)));
        state.ui.last_title_dispatched = Some(current);
    }
}

// ─── helpers ────────────────────────────────────────────────────────

fn handle_key(state: &mut State, cmds: &mut Vec<Cmd>, code: KeyCode, mods: KeyMods) {
    // Ctrl+C is the hard "leave the TUI" path. If work is active,
    // emit a cancellation first so shutdown does not wait on a live
    // provider/tool scope before returning the terminal.
    if mods.ctrl && code == KeyCode::Char('c') {
        request_exit(state, cmds);
        return;
    }

    // Inline approval modal: while a tool awaits approval the prompt is
    // exclusive. Direct keys resolve immediately — 1/y approve · 2/a approve +
    // don't-ask-again · 3/n/Esc deny. Or move the highlight with ↑/↓ and press
    // Enter on it. Sits ABOVE the Esc-cancel guard so Esc denies just this tool
    // (keeping the turn alive) rather than cancelling the whole turn. Any other
    // key is swallowed. Resolving emits `Cmd::ResolveApproval`, which unblocks
    // the parked tool task via the broker.
    if !state.pending_approval.is_empty() {
        use crate::domain::ApprovalChoice;
        // 0 = Yes, 1 = Yes-always, 2 = No.
        const APPROVAL_OPTIONS: usize = 3;
        let choice_for = |idx: usize| match idx {
            0 => ApprovalChoice::Approve,
            1 => ApprovalChoice::ApproveAlways,
            _ => ApprovalChoice::Deny,
        };
        // Copy the current highlight out so the ↑/↓ arms can take a fresh
        // mutable borrow without conflicting.
        let selected = state
            .pending_approval
            .front()
            .map(|i| i.selected_option)
            .unwrap_or(0);
        let choice = match code {
            KeyCode::Char('1') | KeyCode::Char('y') | KeyCode::Char('Y') => {
                Some(ApprovalChoice::Approve)
            },
            KeyCode::Char('2') | KeyCode::Char('a') | KeyCode::Char('A') => {
                Some(ApprovalChoice::ApproveAlways)
            },
            KeyCode::Char('3') | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Escape => {
                Some(ApprovalChoice::Deny)
            },
            KeyCode::Enter => Some(choice_for(selected)),
            KeyCode::Up => {
                if let Some(front) = state.pending_approval.front_mut() {
                    front.selected_option = selected.saturating_sub(1);
                }
                None
            },
            KeyCode::Down => {
                if let Some(front) = state.pending_approval.front_mut() {
                    front.selected_option = (selected + 1).min(APPROVAL_OPTIONS - 1);
                }
                None
            },
            _ => None,
        };
        if let Some(decision) = choice
            && let Some(call_id) = state.pending_approval.front().map(|i| i.call_id)
        {
            state.pending_approval.pop_front();
            cmds.push(Cmd::ResolveApproval { call_id, decision });
        }
        return;
    }

    // Pending confirmation modal (e.g. `/clear`): y/Enter accepts, n/Esc
    // declines. (This handler — and the render side — were missing, so the
    // confirmation was previously inert.)
    if state.confirm.is_some() {
        match code {
            KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
                handle_confirm_accepted(state, cmds);
            },
            KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Escape => {
                state.confirm = None;
            },
            _ => {},
        }
        return;
    }

    // Esc interrupts active work. When idle it falls through to the
    // palette/input/focus handlers below.
    if mods.is_empty() && code == KeyCode::Escape && state.is_busy() {
        if matches!(state.turn, TurnState::Cancelling { .. }) {
            request_exit(state, cmds);
        } else {
            handle_cancel_turn(state, cmds);
        }
        return;
    }

    // Ctrl+D on empty input quits.
    if mods.ctrl && code == KeyCode::Char('d') && state.ui.input_buffer.is_empty() {
        request_exit(state, cmds);
        return;
    }

    // Ctrl+V: read the system clipboard and paste its contents. Gate
    // on `EditingInput` + no confirmation modal so the palette and
    // conversation-list picker don't swallow the keystroke. The
    // actual clipboard read happens off-thread in the effect runner
    // (xclip / wl-paste / pngpaste / PowerShell can block for
    // hundreds of ms on macOS); result comes back as
    // `Msg::Paste(Image|Text)` or `Msg::TransientStatus` on failure.
    if mods.ctrl
        && code == KeyCode::Char('v')
        && matches!(state.ui.mode, UiMode::EditingInput)
        && state.confirm.is_none()
    {
        cmds.push(Cmd::ReadClipboard);
        return;
    }

    // Alt+T cycles reasoning depth. Persists per-model so cycling on
    // Sonnet doesn't bleed into the next session with Ollama.
    if mods.alt && code == KeyCode::Char('t') {
        let next = cycle_reasoning(state.session.reasoning);
        state.session.reasoning = next;
        cmds.push(Cmd::PersistReasoningFor {
            model_id: state.session.model_id.clone(),
            level: next,
        });
        state.status = Some(StatusLine {
            text: format!("Reasoning: {}", next.as_str()),
            kind: StatusKind::Info,
            shown_at: std::time::SystemTime::now(),
        });
        cmds.push(Cmd::DismissStatusAfter { ms: 2_000 });
        return;
    }

    // Shift+Tab cycles the safety mode (read-only → ask → auto → full-access).
    // Session-scoped: the `[safety]` config value stays the persistent default,
    // so a session never silently inherits a more-permissive mode from a
    // previous run. Mirrors the Alt+T reasoning cycle above.
    if code == KeyCode::BackTab {
        let next = cycle_safety(state.session.safety_mode);
        state.session.safety_mode = next;
        state.status = Some(StatusLine {
            text: format!("Safety: {}", next.as_str()),
            kind: StatusKind::Info,
            shown_at: std::time::SystemTime::now(),
        });
        cmds.push(Cmd::DismissStatusAfter { ms: 2_000 });
        return;
    }

    // Conversation-list picker (UiMode::ConversationList): ↑/↓
    // navigate, Enter loads the highlighted session, Esc dismisses.
    if matches!(state.ui.mode, UiMode::ConversationList { .. }) {
        handle_conversation_list_key(state, cmds, code);
        return;
    }

    // Attachment-focus mode: keyboard navigates the bar.
    if state.ui.attachment_focused {
        handle_attachment_key(state, code);
        return;
    }

    // Slash-palette navigation — intercepts ↑/↓/Tab/Esc while the
    // input buffer opens with `/`. Enter falls through to the normal
    // handler below so the command actually dispatches.
    if state.ui.input_buffer.starts_with('/') {
        use crate::domain::slash_commands::filter_by_prefix;
        let typed = state
            .ui
            .input_buffer
            .trim_start_matches('/')
            .split_whitespace()
            .next()
            .unwrap_or("");
        let candidates = filter_by_prefix(typed);
        match code {
            KeyCode::Up => {
                let cur = state.ui.palette_cursor.unwrap_or(0);
                state.ui.palette_cursor = Some(cur.saturating_sub(1));
                return;
            },
            KeyCode::Down => {
                let max = candidates.len().saturating_sub(1);
                let cur = state.ui.palette_cursor.unwrap_or(0);
                state.ui.palette_cursor = Some((cur + 1).min(max));
                return;
            },
            KeyCode::Tab => {
                let sel = state.ui.palette_cursor.unwrap_or(0);
                if let Some(cmd) = candidates.get(sel) {
                    state.ui.input_buffer = format!("/{} ", cmd.name);
                    state.ui.input_cursor = state.ui.input_buffer.len();
                    state.ui.palette_cursor = Some(0);
                }
                return;
            },
            KeyCode::Escape => {
                state.ui.input_buffer.clear();
                state.ui.input_cursor = 0;
                state.ui.palette_cursor = None;
                return;
            },
            KeyCode::Enter if !mods.shift => {
                // Complete-then-execute: replace the command word with
                // the highlighted candidate (preserving any args the
                // user already typed), then fall through to the Enter
                // handler below so the command actually dispatches.
                let sel = state.ui.palette_cursor.unwrap_or(0);
                if let Some(cmd) = candidates.get(sel) {
                    let raw = state.ui.input_buffer.clone();
                    let after_slash = raw.trim_start_matches('/');
                    let rest = match after_slash.find(char::is_whitespace) {
                        Some(idx) => &after_slash[idx..],
                        None => "",
                    };
                    state.ui.input_buffer = format!("/{}{}", cmd.name, rest);
                    state.ui.input_cursor = state.ui.input_buffer.len();
                }
                // Fall through to the Enter handler below.
            },
            _ => {
                // Fall through to normal key handling (char/Backspace
                // update the filter; palette_cursor gets reset below).
            },
        }
    }

    // Enter submits the current input (or triggers the slash palette
    // pick). Shift+Enter is a newline for multi-line input. This arm
    // enqueues a synthetic `Msg` on `pending_msgs` rather than
    // invoking the dispatch directly — the outer `update()` drain
    // will run the follow-up with stale-filter + pending-msgs
    // guarantees intact.
    if code == KeyCode::Enter && !mods.shift {
        let buf = state.ui.input_buffer.trim().to_string();
        if buf.is_empty() {
            return;
        }
        if let Some(rest) = buf.strip_prefix('/') {
            let slash = crate::app::event_source::parse_slash_command(rest);
            state.ui.input_buffer.clear();
            state.ui.input_cursor = 0;
            state.ui.palette_cursor = None;
            state.ui.pending_msgs.push_back(Msg::Slash(slash));
        } else {
            let text = std::mem::take(&mut state.ui.input_buffer);
            state.ui.input_cursor = 0;
            let attachment_ids: Vec<u64> = state.ui.attachments.iter().map(|a| a.id).collect();
            state.ui.pending_msgs.push_back(Msg::SubmitPrompt {
                text,
                attachment_ids,
            });
        }
        return;
    }

    if mods.is_empty() || mods.shift {
        match code {
            KeyCode::Char(c) => {
                // Any text mutation resets history nav — the user's
                // typing wins over whatever historical entry was
                // on-screen.
                state.ui.input_history_cursor = None;
                state.ui.history_draft.clear();
                let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
                state.ui.input_buffer.insert(pos, c);
                state.ui.input_cursor = clamp_cursor(&state.ui.input_buffer, pos + c.len_utf8());
                // Opening the palette, or editing its filter, resets
                // the cursor to the first candidate — stops stale
                // indices from pointing past the end of a shrinking
                // filter result.
                if state.ui.input_buffer.starts_with('/') {
                    state.ui.palette_cursor = Some(0);
                }
            },
            KeyCode::Backspace => {
                state.ui.input_history_cursor = None;
                state.ui.history_draft.clear();
                let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
                if pos > 0 {
                    let new_pos = state.ui.input_buffer.floor_char_boundary(pos - 1);
                    state.ui.input_buffer.drain(new_pos..pos);
                    state.ui.input_cursor = new_pos;
                }
                if state.ui.input_buffer.starts_with('/') {
                    state.ui.palette_cursor = Some(0);
                } else {
                    state.ui.palette_cursor = None;
                }
            },
            KeyCode::Delete => {
                state.ui.input_history_cursor = None;
                state.ui.history_draft.clear();
                let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
                if pos < state.ui.input_buffer.len() {
                    let next = state.ui.input_buffer.ceil_char_boundary(pos + 1);
                    state.ui.input_buffer.drain(pos..next);
                }
                if state.ui.input_buffer.starts_with('/') {
                    state.ui.palette_cursor = Some(0);
                } else {
                    state.ui.palette_cursor = None;
                }
            },
            KeyCode::Left => {
                let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
                if pos > 0 {
                    state.ui.input_cursor = state.ui.input_buffer.floor_char_boundary(pos - 1);
                }
            },
            KeyCode::Right => {
                let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
                if pos < state.ui.input_buffer.len() {
                    state.ui.input_cursor = state.ui.input_buffer.ceil_char_boundary(pos + 1);
                }
            },
            KeyCode::Home => state.ui.input_cursor = 0,
            KeyCode::End => state.ui.input_cursor = state.ui.input_buffer.len(),
            KeyCode::Up => {
                // Up precedence: attachment focus wins ONLY when the
                // input is empty AND attachments exist — otherwise
                // step back through input history.
                if state.ui.input_buffer.is_empty() && !state.ui.attachments.is_empty() {
                    state.ui.attachment_focused = true;
                    state.ui.attachment_selected = state
                        .ui
                        .attachment_selected
                        .min(state.ui.attachments.len() - 1);
                } else {
                    history_nav_back(state);
                }
            },
            KeyCode::Down => {
                history_nav_forward(state);
            },
            KeyCode::Escape => {
                state.ui.attachment_focused = false;
                // Also clear any in-progress history nav.
                state.ui.input_history_cursor = None;
                state.ui.history_draft.clear();
            },
            _ => {},
        }
    }
}

/// Handle keyboard input while the conversation-list picker is open.
/// Up/Down walk the cursor within the candidate list; Enter loads the
/// highlighted session; Esc dismisses.
fn handle_conversation_list_key(state: &mut State, cmds: &mut Vec<Cmd>, code: KeyCode) {
    let UiMode::ConversationList {
        ref candidates,
        ref mut cursor,
    } = state.ui.mode
    else {
        return;
    };
    match code {
        KeyCode::Up => {
            *cursor = cursor.saturating_sub(1);
        },
        KeyCode::Down => {
            let max = candidates.len().saturating_sub(1);
            if *cursor < max {
                *cursor += 1;
            }
        },
        KeyCode::Enter => {
            if let Some(summary) = candidates.get(*cursor) {
                cmds.push(Cmd::LoadConversation(summary.id.clone()));
            }
            // Mode flips on `Msg::ConversationLoaded` — leave as-is
            // until then so the user sees the list until the load
            // completes.
        },
        KeyCode::Escape => {
            state.ui.mode = UiMode::EditingInput;
        },
        _ => {},
    }
}

/// Handle keyboard input while the attachment bar has keyboard
/// focus. Returns without emitting Cmds; attachment removal happens
/// inline on state.ui.attachments.
fn handle_attachment_key(state: &mut State, code: KeyCode) {
    match code {
        KeyCode::Escape | KeyCode::Down => {
            state.ui.attachment_focused = false;
        },
        KeyCode::Left => {
            if !state.ui.attachments.is_empty() {
                state.ui.attachment_selected = state
                    .ui
                    .attachment_selected
                    .checked_sub(1)
                    .unwrap_or(state.ui.attachments.len() - 1);
            }
        },
        KeyCode::Right => {
            if !state.ui.attachments.is_empty() {
                state.ui.attachment_selected =
                    (state.ui.attachment_selected + 1) % state.ui.attachments.len();
            }
        },
        KeyCode::Delete | KeyCode::Backspace => {
            let idx = state.ui.attachment_selected;
            if idx < state.ui.attachments.len() {
                state.ui.attachments.remove(idx);
            }
            if state.ui.attachments.is_empty() {
                state.ui.attachment_focused = false;
                state.ui.attachment_selected = 0;
            } else if state.ui.attachment_selected >= state.ui.attachments.len() {
                state.ui.attachment_selected = state.ui.attachments.len() - 1;
            }
        },
        _ => {},
    }
}

/// Clamp a raw byte offset onto the nearest preceding char boundary
/// in `s`. Callers that trust their cursor is already valid can skip
/// this; paste + multi-step transformations should use it.
fn clamp_cursor(s: &str, pos: usize) -> usize {
    let capped = pos.min(s.len());
    s.floor_char_boundary(capped)
}

/// Step BACK through input history (Up arrow). The first press saves
/// the user's in-progress draft and replaces the buffer with the
/// newest history entry; subsequent presses step older.
fn history_nav_back(state: &mut State) {
    let history = &state.session.conversation.input_history;
    if history.is_empty() {
        return;
    }
    let next_cursor = match state.ui.input_history_cursor {
        None => {
            // First Up press — snapshot the current draft.
            state.ui.history_draft = state.ui.input_buffer.clone();
            0
        },
        Some(i) => (i + 1).min(history.len() - 1),
    };
    state.ui.input_history_cursor = Some(next_cursor);
    // `input_history` is a VecDeque with newest at the back. Index
    // 0 from the end = newest, 1 = one older, etc.
    let historical = history
        .iter()
        .rev()
        .nth(next_cursor)
        .cloned()
        .unwrap_or_default();
    state.ui.input_buffer = historical;
    state.ui.input_cursor = state.ui.input_buffer.len();
}

/// Step FORWARD through input history (Down arrow). Stepping past
/// the newest entry restores the user's original draft.
fn history_nav_forward(state: &mut State) {
    let Some(cursor) = state.ui.input_history_cursor else {
        return;
    };
    if cursor == 0 {
        // Back to the live draft.
        state.ui.input_buffer = std::mem::take(&mut state.ui.history_draft);
        state.ui.input_cursor = state.ui.input_buffer.len();
        state.ui.input_history_cursor = None;
        return;
    }
    let new_cursor = cursor - 1;
    state.ui.input_history_cursor = Some(new_cursor);
    let historical = state
        .session
        .conversation
        .input_history
        .iter()
        .rev()
        .nth(new_cursor)
        .cloned()
        .unwrap_or_default();
    state.ui.input_buffer = historical;
    state.ui.input_cursor = state.ui.input_buffer.len();
}

/// Cycle ReasoningLevel through every variant, wrapping around. Used
/// by Alt+T. Order matches the `Ord` impl so the cycle walks from
/// lowest to highest and back to None.
fn cycle_reasoning(current: crate::models::ReasoningLevel) -> crate::models::ReasoningLevel {
    use crate::models::ReasoningLevel as R;
    match current {
        R::None => R::Minimal,
        R::Minimal => R::Low,
        R::Low => R::Medium,
        R::Medium => R::High,
        R::High => R::XHigh,
        R::XHigh => R::Max,
        R::Max => R::None,
    }
}

/// Cycle SafetyMode by increasing permissiveness, wrapping around. Used by
/// Shift+Tab: ReadOnly → Ask → Auto → FullAccess → ReadOnly.
fn cycle_safety(current: crate::runtime::SafetyMode) -> crate::runtime::SafetyMode {
    use crate::runtime::SafetyMode as S;
    match current {
        S::ReadOnly => S::Ask,
        S::Ask => S::Auto,
        S::Auto => S::FullAccess,
        S::FullAccess => S::ReadOnly,
    }
}

fn handle_paste(state: &mut State, cmds: &mut Vec<Cmd>, paste: Paste) {
    match paste {
        Paste::Text(t) => {
            // Insert at the cursor and advance it, exactly like typing. Pasted
            // text and individual key presses MUST agree on position: on the
            // Windows console a paste arrives as a mix of coalesced `Paste`
            // chunks and stray `Char` key events, so appending to the end here
            // while keys insert at the cursor scrambled the result (uppercase
            // letters piled at the front).
            state.ui.input_history_cursor = None;
            state.ui.history_draft.clear();
            let pos = clamp_cursor(&state.ui.input_buffer, state.ui.input_cursor);
            state.ui.input_buffer.insert_str(pos, &t);
            state.ui.input_cursor = clamp_cursor(&state.ui.input_buffer, pos + t.len());
            if state.ui.input_buffer.starts_with('/') {
                state.ui.palette_cursor = Some(0);
            }
        },
        Paste::Image { bytes, format } => {
            let id = state.ids.tool_call.next();
            let temp_path = std::env::temp_dir().join(format!("mermaid-img-{}.{}", id, format));
            state.ui.attachments.push(super::state::Attachment {
                id,
                base64_data: base64::Engine::encode(
                    &base64::engine::general_purpose::STANDARD,
                    &bytes,
                ),
                temp_path: temp_path.clone(),
                size_bytes: bytes.len(),
                format: format.clone(),
            });
            cmds.push(Cmd::WriteImageToTemp {
                path: temp_path,
                bytes,
                format,
            });
        },
    }
}

fn handle_submit_prompt(
    state: &mut State,
    cmds: &mut Vec<Cmd>,
    text: String,
    attachment_ids: &[u64],
) {
    if text.trim().is_empty() {
        return;
    }
    // If a turn is already in flight, queue this message. The
    // reducer's StreamDone arm pops the oldest queued message and
    // auto-submits it.
    if !matches!(state.turn, TurnState::Idle) {
        state.ui.queued_messages.push_back(text);
        return;
    }

    // Consume attachments by ID — ignoring stale IDs gracefully.
    let mut images: Vec<String> = Vec::new();
    state.ui.attachments.retain(|a| {
        if attachment_ids.contains(&a.id) {
            images.push(a.base64_data.clone());
            false
        } else {
            true
        }
    });

    let mut user_msg = ChatMessage::user(text.clone());
    if !images.is_empty() {
        user_msg = user_msg.with_images(images);
    }
    state.session.append(user_msg);
    state.session.conversation.add_to_input_history(text);
    state.ui.input_buffer.clear();

    // The first user message derives the conversation title; every
    // subsequent message keeps it. Either way, emit SetTerminalTitle
    // only on actual change.
    emit_title_if_changed(state, cmds);

    // F8: refresh MERMAID.md synchronously BEFORE building the chat
    // request, so edits the user made between turns land in the very
    // next call instead of the one after. The reducer's "no I/O" law
    // has a deliberate exception for this: a single `stat` on a local
    // file (plus an optional <40KB read on mtime change) is
    // microseconds and doesn't need an async ceremony. See
    // `crate::app::instructions::refresh`.
    let (refreshed, _outcome) =
        crate::app::instructions::refresh(state.instructions.take(), &state.cwd);
    state.instructions = refreshed;
    let (refreshed_memory, _) =
        crate::app::memory::refresh(state.memory.take(), &state.cwd, &state.settings.memory);
    state.memory = refreshed_memory;

    let turn = state.ids.fresh_turn();
    state.turn = start_generating(turn);
    cmds.push(Cmd::CallModel {
        turn,
        request: build_chat_request(state),
    });
}

fn handle_slash(state: &mut State, cmds: &mut Vec<Cmd>, cmd: SlashCmd) {
    match cmd {
        SlashCmd::Model(None) => {
            state.status = Some(StatusLine {
                text: format!("Current model: {}", state.session.model_id),
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
        },
        SlashCmd::Model(Some(new_model)) => {
            let pull_target = ollama_pull_target(&new_model);
            state.session.model_id = new_model.clone();
            state.runtime.set_model(&new_model);
            state.status = Some(StatusLine {
                text: format!("Model: {}", new_model),
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::PersistLastModel(new_model));
            if let Some(model) = pull_target {
                cmds.push(Cmd::PullOllamaModel { model });
            }
            cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
        },
        SlashCmd::Reasoning(None) => {
            state.status = Some(StatusLine {
                text: format!("Reasoning: {}", state.session.reasoning.as_str()),
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
        },
        SlashCmd::Reasoning(Some(level)) => {
            state.session.reasoning = level;
            cmds.push(Cmd::PersistReasoningFor {
                model_id: state.session.model_id.clone(),
                level,
            });
        },
        SlashCmd::Safety(None) => {
            state.status = Some(StatusLine {
                text: format!(
                    "Safety: {} — options: read_only, ask, auto, full_access (Shift+Tab cycles)",
                    state.session.safety_mode.as_str()
                ),
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::DismissStatusAfter { ms: 4_000 });
        },
        SlashCmd::Safety(Some(mode)) => {
            // Session-scoped (mirrors Shift+Tab) — not written to the config.
            state.session.safety_mode = mode;
            state.status = Some(StatusLine {
                text: format!("Safety: {}", mode.as_str()),
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::DismissStatusAfter { ms: 2_000 });
        },
        SlashCmd::VisibleReasoning(arg) => {
            match visible_reasoning_value(arg.as_deref(), state.ui.show_reasoning) {
                Ok(next) => {
                    state.ui.show_reasoning = next;
                    set_status(
                        state,
                        cmds,
                        if next {
                            "Visible reasoning: on"
                        } else {
                            "Visible reasoning: off"
                        },
                        StatusKind::Info,
                        3_000,
                    );
                },
                Err(usage) => {
                    set_status(state, cmds, usage, StatusKind::Warn, 4_000);
                },
            }
        },
        SlashCmd::Clear => {
            // Guard with a confirmation modal.
            state.confirm = Some(super::state::Confirmation {
                prompt: "Clear conversation history?".to_string(),
                accept_msg_token: super::state::ConfirmationTarget::ClearConversation,
            });
        },
        SlashCmd::Save(_name) => {
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        SlashCmd::Load(Some(id)) => {
            cmds.push(Cmd::LoadConversation(id));
        },
        SlashCmd::Load(None) | SlashCmd::List => {
            // Transition to the picker. Effect handler scans the
            // conversations directory; the reducer fills in
            // candidates when `Msg::ConversationsListed` arrives.
            state.ui.mode = UiMode::ConversationList {
                candidates: Vec::new(),
                cursor: 0,
            };
            cmds.push(Cmd::ListConversations);
        },
        SlashCmd::Usage => {
            state.session.append(ChatMessage::system(usage_text(state)));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        SlashCmd::Context => {
            state
                .session
                .append(ChatMessage::system(context_text(state)));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        SlashCmd::Compact(instructions) => {
            handle_manual_compact(state, cmds, instructions);
        },
        SlashCmd::Memory => {
            cmds.push(Cmd::ListMemory);
        },
        SlashCmd::Remember(Some(text)) => {
            cmds.push(Cmd::RememberMemory { text });
        },
        SlashCmd::Remember(None) => {
            set_status(
                state,
                cmds,
                "Usage: /remember <fact to remember>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Forget(Some(id)) => {
            cmds.push(Cmd::ForgetMemory { id });
        },
        SlashCmd::Forget(None) => {
            set_status(
                state,
                cmds,
                "Usage: /forget <memory name> (see /memory for names)",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::ConsolidateMemory => {
            cmds.push(Cmd::ConsolidateMemory {
                model_id: state.session.model_id.clone(),
            });
        },
        SlashCmd::Doctor => {
            state
                .session
                .append(ChatMessage::system(doctor_text(state)));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        SlashCmd::Tasks => {
            cmds.push(Cmd::ListRuntimeTasks { limit: 10 });
        },
        SlashCmd::Task(Some(id)) => {
            cmds.push(Cmd::LoadRuntimeTask { id });
        },
        SlashCmd::Task(None) => {
            set_status(state, cmds, "Usage: /task <id>", StatusKind::Info, 3_000);
        },
        SlashCmd::Pause(Some(id)) => {
            cmds.push(Cmd::UpdateRuntimeTaskStatus {
                id,
                status: TaskStatus::Blocked,
                final_report: Some("Paused from TUI".to_string()),
            });
        },
        SlashCmd::Pause(None) => {
            set_status(
                state,
                cmds,
                "Usage: /pause <task-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Resume(Some(id)) => {
            cmds.push(Cmd::UpdateRuntimeTaskStatus {
                id,
                status: TaskStatus::Running,
                final_report: None,
            });
        },
        SlashCmd::Resume(None) => {
            set_status(
                state,
                cmds,
                "Usage: /resume <task-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Cancel(Some(id)) => {
            cmds.push(Cmd::UpdateRuntimeTaskStatus {
                id,
                status: TaskStatus::Cancelled,
                final_report: Some("Cancelled from TUI".to_string()),
            });
        },
        SlashCmd::Cancel(None) => {
            if matches!(state.turn, TurnState::Idle) {
                set_status(
                    state,
                    cmds,
                    "No active turn to cancel.",
                    StatusKind::Info,
                    2_500,
                );
            } else {
                handle_cancel_turn(state, cmds);
            }
        },
        SlashCmd::Handoff(Some(id)) | SlashCmd::Report(Some(id)) => {
            cmds.push(Cmd::LoadRuntimeTask { id });
        },
        SlashCmd::Handoff(None) => {
            let text = format!(
                "Handoff report\n\n{}\n\n{}",
                context_text(state),
                usage_text(state)
            );
            state.session.append(ChatMessage::system(text));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        SlashCmd::Report(None) => {
            let text = format!(
                "Runtime report\n\n{}\n\n{}",
                context_text(state),
                usage_text(state)
            );
            state.session.append(ChatMessage::system(text));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        SlashCmd::Processes => {
            cmds.push(Cmd::ListRuntimeProcesses { limit: 10 });
        },
        SlashCmd::Logs(Some(id)) => {
            cmds.push(Cmd::ShowRuntimeProcessLogs { id });
        },
        SlashCmd::Logs(None) => {
            set_status(
                state,
                cmds,
                "Usage: /logs <process-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Stop(Some(id)) => {
            cmds.push(Cmd::StopRuntimeProcess { id });
        },
        SlashCmd::Stop(None) => {
            set_status(
                state,
                cmds,
                "Usage: /stop <process-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Restart(Some(id)) => {
            cmds.push(Cmd::RestartRuntimeProcess { id });
        },
        SlashCmd::Restart(None) => {
            set_status(
                state,
                cmds,
                "Usage: /restart <process-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Open(Some(target)) => {
            cmds.push(Cmd::OpenRuntimeTarget { target });
        },
        SlashCmd::Open(None) => {
            set_status(
                state,
                cmds,
                "Usage: /open <url|path|process-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Ports => {
            cmds.push(Cmd::ShowRuntimePorts);
        },
        SlashCmd::Approvals => {
            cmds.push(Cmd::ListRuntimeApprovals);
        },
        SlashCmd::Approve(Some(id)) => {
            cmds.push(Cmd::DecideRuntimeApproval {
                id,
                decision: "approved".to_string(),
            });
        },
        SlashCmd::Approve(None) => {
            set_status(
                state,
                cmds,
                "Usage: /approve <approval-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Deny(Some(id)) => {
            cmds.push(Cmd::DecideRuntimeApproval {
                id,
                decision: "denied".to_string(),
            });
        },
        SlashCmd::Deny(None) => {
            set_status(
                state,
                cmds,
                "Usage: /deny <approval-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Checkpoint(Some(paths)) => {
            let paths = paths
                .split_whitespace()
                .map(std::path::PathBuf::from)
                .collect::<Vec<_>>();
            cmds.push(Cmd::CreateRuntimeCheckpoint { paths });
        },
        SlashCmd::Checkpoint(None) => {
            set_status(
                state,
                cmds,
                "Usage: /checkpoint <path...>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Checkpoints => {
            cmds.push(Cmd::ListRuntimeCheckpoints { limit: 10 });
        },
        SlashCmd::Restore(Some(id)) => {
            cmds.push(Cmd::RestoreRuntimeCheckpoint { id });
        },
        SlashCmd::Restore(None) => {
            set_status(
                state,
                cmds,
                "Usage: /restore <checkpoint-id>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::Plugins => {
            cmds.push(Cmd::ListRuntimePlugins);
        },
        SlashCmd::ModelInfo(Some(model)) => {
            cmds.push(Cmd::ShowRuntimeModelInfo { model });
        },
        SlashCmd::ModelInfo(None) => {
            set_status(
                state,
                cmds,
                "Usage: /model-info <model>",
                StatusKind::Info,
                3_000,
            );
        },
        SlashCmd::CloudSetup => {
            // Cloud setup needs interactive stdin (rpassword) which
            // fights with ratatui's raw mode. The in-TUI command
            // points users at the `mermaid cloud-setup` subcommand
            // instead — clean separation of modes.
            state.status = Some(StatusLine {
                text: "Run `mermaid cloud-setup` from your shell, then restart mermaid."
                    .to_string(),
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::DismissStatusAfter { ms: 5_000 });
        },
        SlashCmd::Help => {
            state.session.append(ChatMessage::system(help_text()));
            cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
        },
        SlashCmd::Quit => {
            request_exit(state, cmds);
        },
        SlashCmd::Unknown(name) => {
            state.status = Some(StatusLine {
                text: format!("Unknown command: /{}", name),
                kind: StatusKind::Warn,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::DismissStatusAfter { ms: 2_500 });
        },
    }
}

fn visible_reasoning_value(arg: Option<&str>, current: bool) -> Result<bool, &'static str> {
    match arg.map(str::trim).filter(|s| !s.is_empty()) {
        None | Some("toggle") => Ok(!current),
        Some("on") | Some("true") | Some("yes") | Some("show") => Ok(true),
        Some("off") | Some("false") | Some("no") | Some("hide") => Ok(false),
        Some(_) => Err("Usage: /visible-reasoning [on|off|toggle]"),
    }
}

fn set_status(
    state: &mut State,
    cmds: &mut Vec<Cmd>,
    text: impl Into<String>,
    kind: StatusKind,
    dismiss_ms: u64,
) {
    state.status = Some(StatusLine {
        text: text.into(),
        kind,
        shown_at: std::time::SystemTime::now(),
    });
    cmds.push(Cmd::DismissStatusAfter { ms: dismiss_ms });
}

fn ollama_pull_target(model_id: &str) -> Option<String> {
    let model_id = model_id.trim();
    if model_id.is_empty() {
        return None;
    }
    let (provider, model) = match model_id.split_once('/') {
        Some((provider, model)) => (provider, model),
        None => ("ollama", model_id),
    };
    if !provider.eq_ignore_ascii_case("ollama") {
        return None;
    }
    let model = model.trim();
    if model.is_empty() || model.ends_with(":cloud") {
        None
    } else {
        Some(model.to_string())
    }
}

fn handle_manual_compact(state: &mut State, cmds: &mut Vec<Cmd>, instructions: Option<String>) {
    if !matches!(state.turn, TurnState::Idle) {
        state.status = Some(StatusLine {
            text: "Cannot compact while a turn is active.".to_string(),
            kind: StatusKind::Warn,
            shown_at: std::time::SystemTime::now(),
        });
        cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
        return;
    }

    if state.session.messages().len() < 3 {
        state.status = Some(StatusLine {
            text: "Not enough conversation history to compact.".to_string(),
            kind: StatusKind::Info,
            shown_at: std::time::SystemTime::now(),
        });
        cmds.push(Cmd::DismissStatusAfter { ms: 3_000 });
        return;
    }

    let (refreshed, _outcome) =
        crate::app::instructions::refresh(state.instructions.take(), &state.cwd);
    state.instructions = refreshed;
    let (refreshed_memory, _) =
        crate::app::memory::refresh(state.memory.take(), &state.cwd, &state.settings.memory);
    state.memory = refreshed_memory;

    let turn = state.ids.fresh_turn();
    state.turn = TurnState::Compacting {
        id: turn,
        started: std::time::SystemTime::now(),
        trigger: CompactionTrigger::Manual,
    };
    // The live "Compacting…" status comes from the TurnState::Compacting status
    // line (the blue indicator); no separate gray status message — it was a
    // redundant duplicate. The completion receipt is set on CompactionFinished.
    cmds.push(Cmd::CompactConversation {
        turn,
        request: CompactionRequest::manual(build_chat_request(state), instructions),
    });
}

fn help_text() -> String {
    let mut lines = Vec::with_capacity(COMMAND_REGISTRY.len() + COMMAND_GROUPS.len() + 2);
    lines.push("Mermaid commands".to_string());
    lines.push(
        "Everyday commands are first; daemon/task/process commands are advanced runtime."
            .to_string(),
    );
    for group in COMMAND_GROUPS {
        lines.push(String::new());
        lines.push(format!("{}:", group.title()));
        for command in COMMAND_REGISTRY
            .iter()
            .filter(|command| command.group == *group)
        {
            let hint = command.arg_hint.unwrap_or("");
            let aliases = if command.aliases.is_empty() {
                String::new()
            } else {
                format!(" ({})", command.aliases.join(", "))
            };
            let suffix = if hint.is_empty() {
                String::new()
            } else {
                format!(" {}", hint)
            };
            lines.push(format!(
                "  /{}{}{} - {}",
                command.name, suffix, aliases, command.description
            ));
        }
    }
    lines.join("\n")
}

fn doctor_text(state: &State) -> String {
    let mut lines = Vec::new();
    lines.push("Mermaid Doctor".to_string());
    lines.push(format!("Project: {}", state.cwd.display()));
    lines.push(format!("Active model: {}", state.session.model_id));
    lines.push(format!("Reasoning: {}", state.session.reasoning.as_str()));
    lines.push(format!(
        "Provider: {} / {}",
        state.runtime.provider_capabilities.provider, state.runtime.provider_capabilities.model
    ));
    lines.push(format!(
        "Model capabilities: tools={}, vision={}, reasoning={}, context={}",
        state.runtime.provider_capabilities.supports_tools,
        state.runtime.provider_capabilities.supports_vision,
        state.runtime.provider_capabilities.reasoning,
        state
            .runtime
            .provider_capabilities
            .max_context_tokens
            .map(|n| n.to_string())
            .unwrap_or_else(|| "unknown".to_string())
    ));
    lines.push(format!(
        "Safety: mode={}, checkpoint_on_mutation={}",
        safety_mode_name(state.settings.safety.mode),
        state.settings.safety.checkpoint_on_mutation
    ));
    lines.push(format!(
        "Prompt: {}",
        if state.settings.prompt.is_customized() {
            "customized for this invocation"
        } else {
            "default"
        }
    ));
    match &state.instructions {
        Some(instructions) => lines.push(format!(
            "Project instructions: {} bytes from {} source(s){}",
            instructions.byte_len,
            instructions.sources.len(),
            if instructions.truncated {
                " (truncated)"
            } else {
                ""
            }
        )),
        None => lines.push("Project instructions: none loaded (AGENTS.md, MERMAID.md)".to_string()),
    }
    lines.push(format!(
        "MCP servers: {} configured, {} ready",
        state.mcp.servers.len(),
        state
            .mcp
            .servers
            .values()
            .filter(|entry| matches!(entry.status, crate::domain::McpServerStatus::Ready))
            .count()
    ));
    lines.push(
        "Useful next commands: /help, /context, /model-info <model>, /compact [focus]".to_string(),
    );
    lines.join("\n")
}

fn safety_mode_name(mode: crate::runtime::SafetyMode) -> &'static str {
    mode.as_str()
}

/// The most recent user message, trimmed and length-capped — used as the
/// Auto-mode classifier's "what is the user trying to do" context. `None`
/// when the session has no user message yet.
fn latest_user_intent(session: &super::state::Session) -> Option<String> {
    const MAX: usize = 2000;
    session
        .messages()
        .iter()
        .rev()
        .find(|m| matches!(m.role, crate::models::MessageRole::User))
        .map(|m| {
            let c = m.content.trim();
            if c.len() > MAX {
                format!("{}…", &c[..c.floor_char_boundary(MAX)])
            } else {
                c.to_string()
            }
        })
}

fn usage_text(state: &State) -> String {
    let mut lines = Vec::new();
    lines.push("Usage".to_string());
    lines.push(format!("Model: {}", state.session.model_id));
    lines.push(String::new());

    match &state.session.context_usage {
        Some(context) => {
            let source = if context.is_estimate() {
                "estimated"
            } else {
                "provider-reported"
            };
            lines.push(format!(
                "Current context: {}{}{}",
                format_compact_count(context.used_tokens),
                context
                    .max_tokens
                    .map(|max| format!(" / {}", format_compact_count(max)))
                    .unwrap_or_else(|| " / unknown".to_string()),
                context
                    .used_percent
                    .map(|p| format!(" ({}%, {})", p, source))
                    .unwrap_or_else(|| format!(" ({})", source))
            ));
        },
        None => lines.push("Current context: n/a".to_string()),
    }

    match state.session.last_token_usage {
        Some(last) => lines.push(format!("Last API request: {}", usage_totals_line(last))),
        None => lines.push("Last API request: n/a".to_string()),
    }
    lines.push(format!(
        "Session processed: {}",
        usage_totals_line(state.session.cumulative_token_usage)
    ));

    lines.join("\n")
}

fn context_text(state: &State) -> String {
    let mut lines = Vec::new();
    lines.push("Context".to_string());
    lines.push(format!("Model: {}", state.session.model_id));
    lines.push(format!(
        "Provider: {}",
        state.runtime.provider_capabilities.provider
    ));
    lines.push(String::new());

    let request = build_chat_request(state);
    let max_context = state
        .session
        .context_usage
        .as_ref()
        .and_then(|snapshot| snapshot.max_tokens)
        .or(state.runtime.provider_capabilities.max_context_tokens);
    let next_snapshot = super::state::estimate_context_usage_for_request(&request, max_context);
    let policy = CompactionPolicy::default();
    let response_reserve = policy.response_reserve(request.max_tokens);
    let usage_summary = match (next_snapshot.used_percent, next_snapshot.max_tokens) {
        (Some(percent), Some(_)) if percent >= policy.auto_threshold_percent => {
            format!("high ({percent}% used)")
        },
        (Some(percent), Some(_)) if percent >= 70 => format!("getting full ({percent}% used)"),
        (Some(percent), Some(_)) => format!("comfortable ({percent}% used)"),
        _ => "unknown because provider context limit is unknown".to_string(),
    };

    lines.push(format!("Context fullness: {usage_summary}"));
    lines.push(format!(
        "Next request: {}{} (estimated)",
        format_compact_count(next_snapshot.used_tokens),
        next_snapshot
            .max_tokens
            .map(|max| format!(" / {}", format_compact_count(max)))
            .unwrap_or_else(|| " / unknown".to_string())
    ));
    if let Some(remaining) = next_snapshot.remaining_tokens {
        lines.push(format!(
            "Remaining after request: {}",
            format_compact_count(remaining)
        ));
    }
    lines.push(format!(
        "Response reserve: {}",
        format_compact_count(response_reserve)
    ));
    lines.push(format!(
        "Auto compact threshold: {}%",
        policy.auto_threshold_percent
    ));
    let auto_status = match should_auto_compact(&next_snapshot, &request, policy) {
        Ok(()) => "would run before the next model call".to_string(),
        Err(reason) => format!("not needed ({reason})"),
    };
    lines.push(format!("Auto compact: {auto_status}"));
    if auto_status.starts_with("would run") {
        lines.push(
            "Suggested action: continue normally; Mermaid will compact before the next model call."
                .to_string(),
        );
    } else {
        lines.push("Suggested action: no manual compaction needed unless you want a handoff checkpoint now.".to_string());
    }
    lines.push(format!(
        "Hard limit risk: {}",
        if context_exceeds_hard_limit(&next_snapshot, &request, policy) {
            "yes"
        } else {
            "no"
        }
    ));

    if let Some(context) = &state.session.context_usage {
        let source = if context.is_estimate() {
            "estimated"
        } else {
            "provider-reported"
        };
        lines.push(format!(
            "Last reported context: {}{} ({})",
            format_compact_count(context.used_tokens),
            context
                .max_tokens
                .map(|max| format!(" / {}", format_compact_count(max)))
                .unwrap_or_else(|| " / unknown".to_string()),
            source
        ));
    }

    if let Some(breakdown) = &next_snapshot.breakdown {
        lines.push(String::new());
        lines.push("Prompt budget estimate:".to_string());
        lines.push(format!(
            "- system prompt: {}",
            format_compact_count(breakdown.system_tokens)
        ));
        lines.push(format!(
            "- instructions: {}",
            format_compact_count(breakdown.instructions_tokens)
        ));
        lines.push(format!(
            "- messages ({}): {}",
            breakdown.message_count,
            format_compact_count(breakdown.message_tokens)
        ));
        lines.push(format!(
            "- MCP tool schemas ({}): {}",
            breakdown.tool_count,
            format_compact_count(breakdown.tool_schema_tokens)
        ));
        if breakdown.image_count > 0 {
            lines.push(format!("- images: {}", breakdown.image_count));
        }
        lines.push(
            "Built-in tool schemas are measured on the next model call after dispatch enrichment."
                .to_string(),
        );
    }

    if let Some(last) = state.session.conversation.compactions.last() {
        lines.push(String::new());
        lines.push("Last compaction:".to_string());
        lines.push(format!("- trigger: {}", last.trigger.label()));
        lines.push(format!(
            "- context: {} -> {} tokens",
            format_compact_count(last.before_tokens),
            format_compact_count(last.after_tokens)
        ));
        lines.push(format!(
            "- archived: {} messages",
            last.archived_message_count
        ));
        lines.push(format!(
            "- preserved: {} messages",
            last.preserved_message_count
        ));
        lines.push(format!(
            "- verification: {}",
            if last.verified {
                "verified".to_string()
            } else {
                last.verification_error
                    .as_ref()
                    .map(|err| format!("draft fallback ({err})"))
                    .unwrap_or_else(|| "draft fallback".to_string())
            }
        ));
        if let Some(path) = &last.archive_path {
            lines.push(format!("- archive: {}", path));
        }
        lines.push("- inspect: use the archive path above to review the raw messages Mermaid removed from context.".to_string());
    } else {
        lines.push(String::new());
        lines.push("Last compaction: none yet.".to_string());
    }

    lines.join("\n")
}

fn tasks_text(tasks: &[crate::runtime::TaskRecord]) -> String {
    let mut lines = vec!["Tasks".to_string()];
    if tasks.is_empty() {
        lines.push("No tasks recorded yet.".to_string());
        return lines.join("\n");
    }
    for task in tasks {
        lines.push(format!(
            "- {} [{}] {} - {}",
            task.id, task.status, task.priority, task.title
        ));
        lines.push(format!("  project: {}", task.project_path));
        lines.push(format!("  updated: {}", task.updated_at));
    }
    lines.join("\n")
}

fn task_detail_text(
    task: Option<&crate::runtime::TaskRecord>,
    events: &[crate::runtime::TaskTimelineEvent],
) -> String {
    let Some(task) = task else {
        return "Task not found.".to_string();
    };
    let mut lines = vec![
        format!("Task {}", task.id),
        format!("Title: {}", task.title),
        format!("Status: {}", task.status),
        format!("Priority: {}", task.priority),
        format!("Project: {}", task.project_path),
        format!("Model: {}", task.model_id),
        format!("Created: {}", task.created_at),
        format!("Updated: {}", task.updated_at),
    ];
    if let Some(report) = &task.final_report {
        lines.push(String::new());
        lines.push("Final report:".to_string());
        lines.push(report.clone());
    }
    if !events.is_empty() {
        lines.push(String::new());
        lines.push("Timeline:".to_string());
        for event in events {
            lines.push(format!(
                "- {} {}: {}",
                event.created_at, event.kind, event.message
            ));
        }
    }
    lines.join("\n")
}

fn processes_text(processes: &[crate::runtime::ProcessRecord]) -> String {
    let mut lines = vec!["Processes".to_string()];
    if processes.is_empty() {
        lines.push("No processes recorded yet.".to_string());
        return lines.join("\n");
    }
    for process in processes {
        lines.push(format!(
            "- {} pid={} [{}] {}",
            process.id,
            process.pid,
            process.status.as_str(),
            process.command
        ));
        if let Some(task_id) = &process.task_id {
            lines.push(format!("  task: {}", task_id));
        }
        if let Some(url) = &process.detected_url {
            lines.push(format!("  url: {}", url));
        }
        if let Some(log_path) = &process.log_path {
            lines.push(format!("  log: {}", log_path));
        }
    }
    lines.join("\n")
}

fn approvals_text(approvals: &[crate::runtime::ApprovalRecord]) -> String {
    let mut lines = vec!["Approvals".to_string()];
    if approvals.is_empty() {
        lines.push("No pending approvals.".to_string());
        return lines.join("\n");
    }
    for approval in approvals {
        lines.push(format!(
            "- {} [{}] {}",
            approval.id, approval.risk_classification, approval.proposed_action
        ));
        if let Some(checkpoint_id) = &approval.checkpoint_id {
            lines.push(format!("  checkpoint: {}", checkpoint_id));
        }
        if approval.pending_action_json.is_some() {
            lines.push("  pending action: recorded".to_string());
        }
    }
    lines.join("\n")
}

fn checkpoints_text(checkpoints: &[crate::runtime::CheckpointRecord]) -> String {
    let mut lines = vec!["Checkpoints".to_string()];
    if checkpoints.is_empty() {
        lines.push("No checkpoints recorded yet.".to_string());
        return lines.join("\n");
    }
    for checkpoint in checkpoints {
        lines.push(format!(
            "- {} {} {}",
            checkpoint.id, checkpoint.created_at, checkpoint.project_path
        ));
    }
    lines.join("\n")
}

fn plugins_text(plugins: &[crate::runtime::PluginInstallRecord]) -> String {
    let mut lines = vec!["Plugins".to_string()];
    if plugins.is_empty() {
        lines.push("No plugins installed.".to_string());
        return lines.join("\n");
    }
    for plugin in plugins {
        lines.push(format!(
            "- {} [{}] {}",
            plugin.id,
            if plugin.enabled {
                "enabled"
            } else {
                "disabled"
            },
            plugin.source
        ));
    }
    lines.join("\n")
}

fn usage_totals_line(usage: TokenUsageTotals) -> String {
    let mut parts = vec![
        format!("total {}", format_compact_count(usage.total_tokens)),
        format!("input {}", format_compact_count(usage.input_total_tokens())),
        format!(
            "output {}",
            format_compact_count(usage.output_total_tokens())
        ),
    ];
    if usage.cached_input_tokens > 0 {
        parts.push(format!(
            "cache read {}",
            format_compact_count(usage.cached_input_tokens)
        ));
    }
    if usage.cache_creation_input_tokens > 0 {
        parts.push(format!(
            "cache write {}",
            format_compact_count(usage.cache_creation_input_tokens)
        ));
    }
    if usage.reasoning_output_tokens > 0 {
        parts.push(format!(
            "reasoning {}",
            format_compact_count(usage.reasoning_output_tokens)
        ));
    }
    parts.join(", ")
}

fn format_compact_count(value: usize) -> String {
    if value >= 1_000_000 {
        format_scaled(value, 1_000_000, "m")
    } else if value >= 10_000 {
        format_scaled(value, 1_000, "k")
    } else {
        value.to_string()
    }
}

fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
    let whole = value / divisor;
    let decimal = ((value % divisor) * 10) / divisor;
    if decimal == 0 {
        format!("{}{}", whole, suffix)
    } else {
        format!("{}.{}{}", whole, decimal, suffix)
    }
}

fn handle_cancel_turn(state: &mut State, cmds: &mut Vec<Cmd>) {
    let Some(id) = state.turn.id() else {
        return;
    };
    // Already cancelling: don't double-cancel.
    if matches!(state.turn, TurnState::Cancelling { .. }) {
        return;
    }
    cmds.push(Cmd::CancelScope(id));
    // The cancelled turn's tool tasks are aborted; their parked approval
    // requests can never be answered, so drop any queued prompts.
    state.pending_approval.clear();
    state.turn = TurnState::Cancelling {
        id,
        since: std::time::SystemTime::now(),
    };
}

fn request_exit(state: &mut State, cmds: &mut Vec<Cmd>) {
    if state.should_exit {
        return;
    }
    if let Some(id) = state.turn.id() {
        cmds.push(Cmd::CancelScope(id));
    }
    state.should_exit = true;
    state.ui.pending_msgs.clear();
    state.pending_approval.clear();
    cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));
    cmds.push(Cmd::Exit);
}

fn handle_confirm_accepted(state: &mut State, cmds: &mut Vec<Cmd>) {
    let Some(confirm) = state.confirm.take() else {
        return;
    };
    match confirm.accept_msg_token {
        super::state::ConfirmationTarget::ClearConversation => {
            // Clear = start a fresh conversation: new ID, new default
            // title, empty history, zero cumulative tokens. Matches
            // user mental model ("wipe everything").
            let project_path = state.session.conversation.project_path.clone();
            let model_name = state.session.conversation.model_name.clone();
            state.session.conversation =
                crate::session::ConversationHistory::new(project_path, model_name);
            state.session.cumulative_tokens = 0;
            state.session.last_token_usage = None;
            state.session.cumulative_token_usage = TokenUsageTotals::default();
            state.session.context_usage = None;
            emit_title_if_changed(state, cmds);
        },
    }
}

fn handle_compaction_finished(
    state: &mut State,
    cmds: &mut Vec<Cmd>,
    turn: TurnId,
    result: CompactionResult,
) {
    let manual = match state.turn {
        TurnState::Compacting { id, .. } if id == turn => true,
        TurnState::Generating { id, .. } if id == turn => false,
        _ => return,
    };

    let conversation_id = state.session.conversation.id.clone();
    let mut record = result.record;
    record.archive_path = Some(format!(
        ".mermaid/compactions/{}/{}.json",
        conversation_id, record.id
    ));
    let archive = CompactionArchive {
        id: record.id.clone(),
        conversation_id,
        created_at: record.created_at,
        messages: result.archived_messages,
    };

    state
        .session
        .conversation
        .replace_messages(result.replacement_messages);
    state.session.conversation.add_compaction(record.clone());
    state.session.context_usage = Some(result.after_snapshot);

    if let Some(usage) = result.usage {
        let totals = TokenUsageTotals::from_usage(&usage);
        state.session.last_token_usage = Some(totals);
        state.session.cumulative_token_usage.add_assign(totals);
        state.session.cumulative_tokens = state
            .session
            .cumulative_tokens
            .saturating_add(usage.total_tokens);
    }

    if manual {
        state.turn = TurnState::Idle;
    }

    state.status = Some(StatusLine {
        text: compaction_receipt(&record),
        kind: StatusKind::Info,
        shown_at: std::time::SystemTime::now(),
    });
    cmds.push(Cmd::SaveCompactionArchive {
        archive,
        record,
        conversation: state.session.conversation.clone(),
    });
    cmds.push(Cmd::DismissStatusAfter { ms: 5_000 });
}

fn handle_compaction_failed(
    state: &mut State,
    turn: TurnId,
    trigger: CompactionTrigger,
    message: String,
    kind: StatusKind,
) {
    match state.turn {
        TurnState::Compacting { id, .. } if id == turn => {
            state.turn = TurnState::Idle;
        },
        TurnState::Generating { id, .. } if id == turn => {},
        _ => return,
    }

    let prefix = match trigger {
        CompactionTrigger::Manual => "Compaction failed",
        CompactionTrigger::AutoThreshold => "Auto-compaction skipped",
        CompactionTrigger::ContextLimitRetry => "Context-limit compaction failed",
    };
    state.status = Some(StatusLine {
        text: format!("{}: {}", prefix, message),
        kind,
        shown_at: std::time::SystemTime::now(),
    });
}

fn handle_stream_tool_call(
    state: &mut State,
    turn: TurnId,
    call: crate::models::tool_call::ToolCall,
) {
    if let TurnState::Generating {
        id,
        pending_tool_calls,
        ..
    } = &mut state.turn
        && *id == turn
    {
        pending_tool_calls.push(call);
    }
}

fn handle_stream_done(
    state: &mut State,
    cmds: &mut Vec<Cmd>,
    turn: TurnId,
    usage: Option<crate::models::TokenUsage>,
    thinking_signature: Option<String>,
) {
    // Unpack the Generating state, drop it into Idle temporarily;
    // the branch below decides whether to stay Idle (no tool calls)
    // or transition to ExecutingTools (calls buffered).
    let generating = match std::mem::replace(&mut state.turn, TurnState::Idle) {
        TurnState::Generating {
            id,
            partial_text,
            partial_reasoning,
            thinking_signature: accumulated_sig,
            pending_tool_calls,
            ..
        } if id == turn => (
            partial_text,
            partial_reasoning,
            accumulated_sig,
            pending_tool_calls,
        ),
        other => {
            state.turn = other;
            return;
        },
    };

    let (partial_text, partial_reasoning, accumulated_sig, tool_calls) = generating;
    let final_sig = thinking_signature.or(accumulated_sig);

    // Commit the assistant message (with any tool calls attached —
    // the adapter will serialize them into the next conversation
    // turn).
    let msg = commit_assistant_message(
        partial_text,
        partial_reasoning,
        tool_calls.clone(),
        final_sig,
    );
    state.session.append(msg);

    // Provider token usage is per API request. Track both the last
    // reported request and the session total so the footer can label
    // the number honestly instead of presenting a giant raw counter.
    if let Some(u) = usage {
        let totals = TokenUsageTotals::from_usage(&u);
        state.session.last_token_usage = Some(totals);
        state.session.cumulative_token_usage.add_assign(totals);
        state.session.cumulative_tokens = state
            .session
            .cumulative_tokens
            .saturating_add(u.total_tokens);
        let max_context = state
            .session
            .context_usage
            .as_ref()
            .and_then(|snapshot| snapshot.max_tokens)
            .or(state.runtime.provider_capabilities.max_context_tokens);
        let mut context = super::state::ContextUsageSnapshot::from_usage(&u, max_context);
        if let Some(prev) = state.session.context_usage.as_ref()
            && context.breakdown.is_none()
        {
            context.breakdown = prev.breakdown.clone();
        }
        state.session.context_usage = Some(context);
    } else {
        state.session.last_token_usage = None;
    }

    cmds.push(Cmd::SaveConversation(state.session.conversation.clone()));

    // If the model asked for any tools, transition to ExecutingTools
    // and dispatch one ExecuteTool per call. The Vec<Option<ToolOutcome>>
    // invariant now has a real producer — ToolFinished messages
    // populate the slots, and try_complete_outcomes gates the
    // transition to the follow-up Generating turn.
    if !tool_calls.is_empty() {
        let pending: Vec<super::state::PendingToolCall> = tool_calls
            .into_iter()
            .map(|source| super::state::PendingToolCall {
                call_id: state.ids.fresh_tool_call(),
                source,
            })
            .collect();
        // Captured once for the whole batch: the live safety mode + the
        // turn's intent (for the Auto-mode classifier).
        let intent = latest_user_intent(&state.session);
        for call in &pending {
            cmds.push(Cmd::ExecuteTool {
                turn,
                call_id: call.call_id,
                source: call.source.clone(),
                // F7: pass the session's current model id so subagent
                // tools can spawn children against the same provider.
                model_id: state.session.model_id.clone(),
                safety_mode: state.session.safety_mode,
                intent: intent.clone(),
            });
        }
        state.turn = super::transition::start_executing_tools(turn, pending);
        return;
    }

    // No tool calls — turn ends here. Drain the queued-message FIFO.
    // The follow-up goes through `pending_msgs` so the outer
    // `update()` re-enters cleanly — preserves stale-filter
    // semantics instead of inline-invoking.
    if let Some(next) = state.ui.queued_messages.pop_front() {
        let attachment_ids: Vec<u64> = state.ui.attachments.iter().map(|a| a.id).collect();
        state.ui.pending_msgs.push_back(Msg::SubmitPrompt {
            text: next,
            attachment_ids,
        });
    }
}

/// Handle `Msg::OpenImageAt { message_index, image_index }`. Resolves
/// the base64 payload from the committed message history, writes it
/// to a temp file, and dispatches `Cmd::OpenInSystem` so the user's
/// default image viewer opens it. F13.
fn handle_open_image_at(
    state: &mut State,
    cmds: &mut Vec<Cmd>,
    message_index: usize,
    image_index: usize,
) {
    let msg = match state.session.messages().get(message_index) {
        Some(m) => m,
        None => return,
    };
    let Some(images) = msg.images.as_ref() else {
        return;
    };
    let Some(b64) = images.get(image_index) else {
        return;
    };
    use base64::{Engine, engine::general_purpose};
    let Ok(bytes) = general_purpose::STANDARD.decode(b64) else {
        return;
    };
    let id = state.ids.tool_call.next();
    let temp_path = std::env::temp_dir().join(format!("mermaid-img-{}.png", id));
    cmds.push(Cmd::WriteImageToTemp {
        path: temp_path.clone(),
        bytes,
        format: "png".to_string(),
    });
    cmds.push(Cmd::OpenInSystem(temp_path));
}

/// Handle `Msg::TurnCancelled(turn)`. The effect runner's `drop_scope`
/// emits this after the cancelled turn's `TurnScope` drains. Transitions
/// `Cancelling(id) → Idle` when the ids match; also closes out the
/// degenerate case where the scope drained but state is already `Idle`
/// (e.g. the stream-done raced). Drains one queued message on the way
/// out, same as the no-tool-calls tail of `handle_stream_done`.
///
/// Stale filter at the top of `update_step` catches mismatched turn ids
/// before we get here, so this handler is branch-light.
fn handle_turn_cancelled(state: &mut State, turn: TurnId) {
    match state.turn {
        TurnState::Cancelling { id, .. } if id == turn => {
            state.turn = TurnState::Idle;
            if let Some(next) = state.ui.queued_messages.pop_front() {
                let attachment_ids: Vec<u64> = state.ui.attachments.iter().map(|a| a.id).collect();
                state.ui.pending_msgs.push_back(Msg::SubmitPrompt {
                    text: next,
                    attachment_ids,
                });
            }
        },
        _ => {
            // Stream already completed / already idle / stale id —
            // silently no-op. The filter at update_step's top would
            // have caught a truly stale id; this branch handles the
            // benign race where the scope drained after a successful
            // StreamDone committed normally.
        },
    }
}

fn handle_upstream_error(state: &mut State, turn: TurnId, error: crate::models::UserFacingError) {
    // Defense in depth (F4): even though the stale-filter at the top of
    // `update_step` gates on `turn_id()`, re-check here so a future
    // refactor that weakens the filter can't silently wipe the active
    // turn with an error message that belongs to a superseded one.
    if state.turn.id() != Some(turn) {
        return;
    }

    // End the current turn. Surface the error through a single
    // channel — the ActionDisplay attached to an empty assistant
    // message. The chat widget paints ActionDisplays as colored
    // error blocks, so committing to both `content` and `actions`
    // would paint the same error twice.
    //
    // Do NOT also set `state.status`: the F9 banner would render the
    // same error a second time directly above the input, which is
    // just noise. The chat entry is persistent (scrollable);
    // duplicating as a transient banner adds nothing.
    state.turn = TurnState::Idle;
    let msg = ChatMessage {
        role: MessageRole::Assistant,
        content: String::new(),
        timestamp: chrono::Local::now(),
        kind: crate::models::ChatMessageKind::Normal,
        metadata: None,
        actions: vec![super::action::ActionDisplay {
            action_type: "Error".to_string(),
            target: error.summary.clone(),
            result: super::action::ActionResult::Error {
                error: error.message.clone(),
            },
            details: super::action::ActionDetails::Simple,
            duration_seconds: None,
            metadata: None,
        }],
        thinking: None,
        images: None,
        tool_calls: None,
        tool_call_id: None,
        tool_name: None,
        thinking_signature: None,
    };
    state.session.append(msg);
}

/// Route a typed `ProgressEvent` to the right state surface.
///
/// - `Output` / `Status` / `Bytes` → transient status line (same as
///   the old String-chunk behavior, with `Bytes` formatted).
/// - `Artifact` with `image/*` mime → append base64 to the in-flight
///   assistant message's `images` for immediate display in chat.
///   Non-image artifacts surface as a status-line label.
/// - `SubagentToolCall` / `SubagentText` → status line with an
///   indented "subagent:" prefix so nested activity is visible
///   without the parent reducer needing to understand child Msgs.
fn handle_tool_progress(
    state: &mut State,
    cmds: &mut Vec<Cmd>,
    _turn: TurnId,
    event: crate::providers::ProgressEvent,
) {
    use crate::providers::{ProgressEvent, SubagentPhase};
    use base64::{Engine as _, engine::general_purpose};

    /// How long a tool-progress banner sticks after the last update.
    /// Every update pushes a fresh dismiss, so the timer effectively
    /// resets while output keeps flowing; once output stops (including
    /// at tool-finish), the banner clears after this delay. Fixes the
    /// "last ls line stuck above input" bug the user hit after F9.
    const PROGRESS_DISMISS_MS: u64 = 2_000;

    let set_status = |state: &mut State, text: String, cmds: &mut Vec<Cmd>| {
        if !text.trim().is_empty() {
            state.status = Some(StatusLine {
                text,
                kind: StatusKind::Info,
                shown_at: std::time::SystemTime::now(),
            });
            cmds.push(Cmd::DismissStatusAfter {
                ms: PROGRESS_DISMISS_MS,
            });
        }
    };

    match event {
        ProgressEvent::Output(s) | ProgressEvent::Status(s) | ProgressEvent::SubagentText(s) => {
            set_status(state, s, cmds);
        },
        ProgressEvent::Bytes { done, total } => {
            let text = match total {
                Some(t) => format!("{} / {} bytes", done, t),
                None => format!("{} bytes", done),
            };
            set_status(state, text, cmds);
        },
        ProgressEvent::Artifact {
            mime,
            data,
            caption,
        } => {
            if mime.starts_with("image/")
                && matches!(
                    state.turn,
                    TurnState::ExecutingTools { .. } | TurnState::Generating { .. }
                )
                && let Some(last) = state.session.conversation.messages.last_mut()
                && last.role == MessageRole::Assistant
            {
                let encoded = general_purpose::STANDARD.encode(&data);
                last.images.get_or_insert_with(Vec::new).push(encoded);
            }
            let label = caption.unwrap_or_else(|| format!("{} ({}b)", mime, data.len()));
            set_status(state, label, cmds);
        },
        ProgressEvent::SubagentToolCall {
            tool_name, phase, ..
        } => {
            let text = match phase {
                SubagentPhase::Started => format!("  ⎿ subagent: {} …", tool_name),
                SubagentPhase::Finished => format!("  ⎿ subagent: {} ✓", tool_name),
                SubagentPhase::Errored => format!("  ⎿ subagent: {} ✗", tool_name),
            };
            set_status(state, text, cmds);
        },
    }
}

fn handle_tool_finished(
    state: &mut State,
    cmds: &mut Vec<Cmd>,
    turn: TurnId,
    call_id: super::ids::ToolCallId,
    outcome: ToolOutcome,
) {
    // Borrow calls + outcomes simultaneously via a helper to avoid
    // double mutable borrow on `state.turn`.
    let completed = match &mut state.turn {
        TurnState::ExecutingTools {
            id,
            calls,
            outcomes,
            ..
        } if *id == turn => {
            if !fill_outcome(calls, outcomes, call_id, outcome.clone()) {
                return;
            }
            // Attach action display to the last assistant message so
            // the renderer can show it.
            if let Some(call) = calls.iter().find(|c| c.call_id == call_id) {
                let action = action_display_for(call, &outcome);
                if let Some(process) = action
                    .metadata
                    .as_ref()
                    .and_then(|metadata| metadata.process.clone())
                {
                    cmds.push(Cmd::SaveProcess(process.clone()));
                    state.runtime.register_process(process);
                }
                if let Some(last) = state.session.conversation.messages.last_mut()
                    && last.role == MessageRole::Assistant
                {
                    last.actions.push(action);
                }
            }
            try_complete_outcomes(outcomes)
        },
        _ => None,
    };

    if let Some(completed_outcomes) = completed
        && let TurnState::ExecutingTools { id, calls, .. } =
            std::mem::replace(&mut state.turn, TurnState::Idle)
        && id == turn
    {
        // Append each tool message to the conversation, then kick off
        // the follow-up model call.
        let tool_msgs = tool_result_messages(&calls, completed_outcomes);
        for m in tool_msgs {
            state.session.append(m);
        }
        let next_turn = state.ids.fresh_turn();
        state.turn = start_generating(next_turn);
        cmds.push(Cmd::CallModel {
            turn: next_turn,
            request: build_chat_request(state),
        });
    }
}

/// Construct the request the model sees for this turn, pulling in the
/// current message log + the active `MERMAID.md` suffix + the
/// reasoning choice + the tools surface.
pub fn build_chat_request(state: &State) -> ChatRequest {
    // Project instructions + the always-loaded memory index compose into the
    // single dynamic suffix. The memory block carries its own `# Memory`
    // header, so it stays clearly separated from AGENTS.md/MERMAID.md and the
    // model adapters need no changes.
    let instructions = match (
        state.instructions.as_ref().map(|i| i.content.clone()),
        state.memory.as_ref().map(|m| m.index.clone()),
    ) {
        (Some(i), Some(m)) => Some(format!("{i}\n\n{m}")),
        (Some(i), None) => Some(i),
        (None, Some(m)) => Some(m),
        (None, None) => None,
    };

    // Pass user-configured values verbatim. `ModelSettings::default()`
    // already supplies `DEFAULT_TEMPERATURE` / `DEFAULT_MAX_TOKENS`,
    // so config never has a real "zero" — the old `.max(DEFAULT_*)`
    // was clobbering low user settings (e.g. temperature=0.1 became
    // 0.7).
    let settings = &state.settings.default_model;
    let temperature = if settings.temperature > 0.0 {
        settings.temperature
    } else {
        DEFAULT_TEMPERATURE
    };
    let max_tokens = if settings.max_tokens > 0 {
        settings.max_tokens
    } else {
        DEFAULT_MAX_TOKENS
    };

    // MCP tools the model should see — each advertised by a Ready
    // server, fully-qualified as `mcp__<server>__<tool>`. The effect
    // runner prepends built-in tools before dispatching, so this
    // vector is the MCP-only portion.
    let mcp_tools: Vec<crate::domain::ToolDefinition> = state
        .mcp
        .servers
        .iter()
        .filter(|(_, entry)| matches!(entry.status, crate::domain::McpServerStatus::Ready))
        .flat_map(|(server_name, entry)| {
            entry
                .tools
                .iter()
                .map(move |tool| crate::domain::ToolDefinition {
                    name: format!("mcp__{}__{}", server_name, tool.name),
                    description: tool.description.clone(),
                    input_schema: tool.input_schema.clone(),
                })
        })
        .collect();

    ChatRequest {
        model_id: state.session.model_id.clone(),
        messages: evict_stale_screenshots(state.session.messages().to_vec()),
        system_prompt: system_prompt_for_state(state),
        instructions,
        reasoning: state.session.reasoning,
        temperature,
        max_tokens,
        tools: mcp_tools,
    }
}

fn system_prompt_for_state(state: &State) -> String {
    let base = state
        .settings
        .prompt
        .render_system_prompt(&get_system_prompt());
    format!(
        "{}\n\n## Current Session\nCurrent working directory: {}\nSafety mode: {} (live — the user can switch it anytime with Shift+Tab or /safety; trust this over any earlier tool error, and attempt gated actions rather than assuming they will fail).\nTreat this as the project root unless the user specifies a different path.",
        base,
        state.cwd.display(),
        state.session.safety_mode.as_str()
    )
}

/// Walk the message log and retain only the `MAX_RETAINED_SCREENSHOTS`
/// most recent images across the whole conversation. Older messages
/// that had images get `images: None` AND an appended
/// `[Image elided — superseded by newer screenshot]` marker in
/// `content`, so the model still knows something visual was there.
///
/// Why: an agentic GUI loop can generate 10+ screenshots in a single
/// session. At 2MB/PNG that's 20MB uncompressed in every outgoing
/// request body — request bloat compounds across turns and slows the
/// model. The on-screen chat history still shows all images (this
/// transformation is on the CLONED Vec passed to the provider); only
/// the wire payload is slimmed.
fn evict_stale_screenshots(mut messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
    use crate::constants::MAX_RETAINED_SCREENSHOTS;
    let mut seen = 0usize;
    for msg in messages.iter_mut().rev() {
        let Some(imgs) = msg.images.as_ref() else {
            continue;
        };
        if imgs.is_empty() {
            continue;
        }
        if seen < MAX_RETAINED_SCREENSHOTS {
            seen += imgs.len();
            continue;
        }
        // Beyond the cap — elide.
        let elided_count = imgs.len();
        msg.images = None;
        let marker = if elided_count == 1 {
            "\n[Image elided — superseded by newer screenshot]"
        } else {
            "\n[Images elided — superseded by newer screenshots]"
        };
        if !msg.content.ends_with(marker) {
            msg.content.push_str(marker);
        }
    }
    messages
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::Config;
    use crate::domain::msg::{Key, KeyCode, KeyMods};
    use crate::domain::state::{McpServerEntry, McpState, PendingToolCall, UiState};
    use crate::domain::transition::start_executing_tools;
    use std::path::PathBuf;

    fn fresh_state() -> State {
        State::new(
            Config::default(),
            PathBuf::from("/tmp/project"),
            "ollama/test".to_string(),
        )
    }

    #[test]
    fn evict_stale_screenshots_retains_most_recent_and_elides_rest() {
        use crate::constants::MAX_RETAINED_SCREENSHOTS;
        let mut msgs = Vec::new();
        for i in 0..(MAX_RETAINED_SCREENSHOTS + 3) {
            msgs.push(ChatMessage {
                role: MessageRole::Assistant,
                content: format!("turn {}", i),
                timestamp: chrono::Local::now(),
                kind: crate::models::ChatMessageKind::Normal,
                metadata: None,
                actions: vec![],
                thinking: None,
                images: Some(vec![format!("png-base64-{}", i)]),
                tool_calls: None,
                tool_call_id: None,
                tool_name: None,
                thinking_signature: None,
            });
        }
        let out = super::evict_stale_screenshots(msgs);
        // Last MAX_RETAINED_SCREENSHOTS entries still carry images.
        for m in out.iter().rev().take(MAX_RETAINED_SCREENSHOTS) {
            assert!(m.images.is_some(), "most-recent images must survive");
        }
        // Everything before the cap is elided.
        for m in out.iter().rev().skip(MAX_RETAINED_SCREENSHOTS) {
            assert!(m.images.is_none(), "older images must be elided");
            assert!(
                m.content.contains("elided"),
                "elision marker must land in content"
            );
        }
    }

    #[test]
    fn evict_stale_screenshots_preserves_messages_without_images() {
        use crate::constants::MAX_RETAINED_SCREENSHOTS;
        // 5 text-only + 2 with images (under the cap) — nothing should
        // be elided.
        let mut msgs = Vec::new();
        for i in 0..5 {
            msgs.push(ChatMessage {
                role: MessageRole::User,
                content: format!("text only {}", i),
                timestamp: chrono::Local::now(),
                kind: crate::models::ChatMessageKind::Normal,
                metadata: None,
                actions: vec![],
                thinking: None,
                images: None,
                tool_calls: None,
                tool_call_id: None,
                tool_name: None,
                thinking_signature: None,
            });
        }
        for i in 0..2 {
            msgs.push(ChatMessage {
                role: MessageRole::Assistant,
                content: format!("with image {}", i),
                timestamp: chrono::Local::now(),
                kind: crate::models::ChatMessageKind::Normal,
                metadata: None,
                actions: vec![],
                thinking: None,
                images: Some(vec![format!("png-{}", i)]),
                tool_calls: None,
                tool_call_id: None,
                tool_name: None,
                thinking_signature: None,
            });
        }
        const { assert!(2 < MAX_RETAINED_SCREENSHOTS, "test premise") };
        let out = super::evict_stale_screenshots(msgs);
        // All 7 messages unchanged.
        let with_images = out.iter().filter(|m| m.images.is_some()).count();
        assert_eq!(with_images, 2);
        assert!(!out.iter().any(|m| m.content.contains("elided")));
    }

    #[test]
    fn quit_sets_exit_flag_and_emits_save_and_exit() {
        let state = fresh_state();
        let (state, cmds) = update(state, Msg::Quit);
        assert!(state.should_exit);
        assert_eq!(cmds.len(), 2);
        assert!(matches!(cmds[0], Cmd::SaveConversation(_)));
        assert!(matches!(cmds[1], Cmd::Exit));
    }

    #[test]
    fn ctrl_c_on_idle_empty_input_exits() {
        let state = fresh_state();
        let msg = Msg::Key(Key {
            code: KeyCode::Char('c'),
            modifiers: KeyMods::ctrl(),
        });
        let (state, cmds) = update(state, msg);
        assert!(state.should_exit);
        assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
    }

    #[test]
    fn ctrl_c_on_idle_with_input_exits() {
        let mut state = fresh_state();
        state.ui.input_buffer = "partial".to_string();
        let msg = Msg::Key(Key {
            code: KeyCode::Char('c'),
            modifiers: KeyMods::ctrl(),
        });
        let (state, cmds) = update(state, msg);
        assert!(state.should_exit);
        assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
    }

    /// F15: every tool-progress status push must schedule a
    /// dismiss so the last progress line doesn't stick on the banner
    /// forever after the tool finishes. Reported by the user: after
    /// `execute_command ls -la`, the last directory entry sat above
    /// the input indefinitely because F9 surfaced `state.status` but
    /// tool progress never cleared it.
    #[test]
    fn tool_progress_schedules_dismiss_on_banner_update() {
        use crate::providers::ProgressEvent;
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(1));
        let turn = state.current_turn_id().unwrap();

        let (state, cmds) = update(
            state,
            Msg::ToolProgress {
                turn,
                call_id: super::super::ids::ToolCallId(1),
                event: ProgressEvent::Output(
                    "drwxrwxr-x  3 nsabaj nsabaj 4096 Mar 30 14:02 .mermaid".to_string(),
                ),
            },
        );
        assert!(state.status.is_some(), "status should be set from output");
        assert!(
            cmds.iter()
                .any(|c| matches!(c, Cmd::DismissStatusAfter { .. })),
            "tool progress must schedule a dismiss so the banner clears"
        );
    }

    /// F14: Ctrl+V in the chat input emits `Cmd::ReadClipboard`. The
    /// reducer stays pure — the actual clipboard read runs off-thread
    /// in the effect runner.
    #[test]
    fn ctrl_v_in_editing_input_emits_read_clipboard() {
        let state = fresh_state();
        assert!(matches!(state.ui.mode, UiMode::EditingInput));
        let (_, cmds) = update(
            state,
            Msg::Key(Key {
                code: KeyCode::Char('v'),
                modifiers: KeyMods::ctrl(),
            }),
        );
        assert!(
            cmds.iter().any(|c| matches!(c, Cmd::ReadClipboard)),
            "Ctrl+V should dispatch Cmd::ReadClipboard; got tags: {:?}",
            cmds.iter().map(|c| c.tag()).collect::<Vec<_>>(),
        );
    }

    /// F14: Ctrl+V while a confirmation modal is open should NOT
    /// hijack the keystroke — the user might be mid-confirmation and
    /// accidentally paste into dismissed UI. Gated out.
    #[test]
    fn ctrl_v_with_confirm_modal_open_is_noop() {
        let mut state = fresh_state();
        state.confirm = Some(super::super::state::Confirmation {
            prompt: "Clear conversation history?".to_string(),
            accept_msg_token: super::super::state::ConfirmationTarget::ClearConversation,
        });
        let (_, cmds) = update(
            state,
            Msg::Key(Key {
                code: KeyCode::Char('v'),
                modifiers: KeyMods::ctrl(),
            }),
        );
        assert!(!cmds.iter().any(|c| matches!(c, Cmd::ReadClipboard)));
    }

    /// F14: Ctrl+V in the conversation-list picker must not trigger
    /// a clipboard read. The picker has its own key handling.
    #[test]
    fn ctrl_v_in_conversation_list_mode_is_noop() {
        let mut state = fresh_state();
        state.ui.mode = UiMode::ConversationList {
            candidates: Vec::new(),
            cursor: 0,
        };
        let (_, cmds) = update(
            state,
            Msg::Key(Key {
                code: KeyCode::Char('v'),
                modifiers: KeyMods::ctrl(),
            }),
        );
        assert!(!cmds.iter().any(|c| matches!(c, Cmd::ReadClipboard)));
    }

    /// F14: `Msg::TransientStatus` sets `state.status` and schedules
    /// auto-dismissal. This is the generic "async effect wants to show
    /// a banner" path — used by clipboard-read feedback.
    #[test]
    fn transient_status_sets_banner_and_schedules_dismiss() {
        let state = fresh_state();
        let (state, cmds) = update(
            state,
            Msg::TransientStatus {
                text: "Clipboard is empty".to_string(),
                kind: StatusKind::Info,
                dismiss_ms: 2_000,
            },
        );
        let s = state.status.expect("status set");
        assert_eq!(s.text, "Clipboard is empty");
        assert_eq!(s.kind, StatusKind::Info);
        assert!(
            cmds.iter()
                .any(|c| matches!(c, Cmd::DismissStatusAfter { ms: 2_000 }))
        );
    }

    /// F14: a Paste::Image Msg (whatever its origin — bracketed paste,
    /// Ctrl+V via clipboard, or a future drag-drop) creates an
    /// Attachment entry and emits Cmd::WriteImageToTemp. This is the
    /// existing contract; the test pins it so the Ctrl+V wiring has a
    /// known-good downstream to rely on.
    #[test]
    fn paste_image_creates_attachment_and_writes_temp() {
        let state = fresh_state();
        let (state, cmds) = update(
            state,
            Msg::Paste(super::super::msg::Paste::Image {
                bytes: vec![0x89, 0x50, 0x4E, 0x47], // PNG magic bytes
                format: "png".to_string(),
            }),
        );
        assert_eq!(state.ui.attachments.len(), 1);
        let att = &state.ui.attachments[0];
        assert_eq!(att.format, "png");
        assert_eq!(att.size_bytes, 4);
        assert!(cmds.iter().any(|c| {
            matches!(c, Cmd::WriteImageToTemp { path, .. } if path == &att.temp_path)
        }));
    }

    #[test]
    fn open_image_writes_and_opens_the_same_temp_path() {
        let mut state = fresh_state();
        let image =
            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"image bytes");
        state
            .session
            .append(ChatMessage::assistant("image").with_images(vec![image]));

        let (_, cmds) = update(
            state,
            Msg::OpenImageAt {
                message_index: 0,
                image_index: 0,
            },
        );

        let write_path = cmds.iter().find_map(|cmd| match cmd {
            Cmd::WriteImageToTemp { path, .. } => Some(path.clone()),
            _ => None,
        });
        let open_path = cmds.iter().find_map(|cmd| match cmd {
            Cmd::OpenInSystem(path) => Some(path.clone()),
            _ => None,
        });
        assert_eq!(write_path, open_path);
    }

    #[test]
    fn ctrl_c_during_turn_exits_and_cancels_scope() {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(5));
        let msg = Msg::Key(Key {
            code: KeyCode::Char('c'),
            modifiers: KeyMods::ctrl(),
        });
        let (state, cmds) = update(state, msg);
        assert!(state.should_exit);
        assert!(
            cmds.iter()
                .any(|c| matches!(c, Cmd::CancelScope(TurnId(5))))
        );
        assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
    }

    #[test]
    fn runtime_signal_exits_and_records_timeline() {
        let state = fresh_state();
        let (state, cmds) = update(
            state,
            Msg::RuntimeSignal(super::super::runtime::RuntimeSignal::Terminate),
        );
        assert!(state.should_exit);
        assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
        assert!(
            state
                .runtime
                .timeline
                .iter()
                .any(|event| event.message.contains("terminate"))
        );
    }

    #[test]
    fn model_switch_updates_provider_capability_snapshot() {
        let state = fresh_state();
        let (state, cmds) = update(
            state,
            Msg::Slash(SlashCmd::Model(Some(
                "anthropic/claude-opus-4-7".to_string(),
            ))),
        );
        assert_eq!(state.runtime.provider_capabilities.provider, "anthropic");
        assert!(state.runtime.provider_capabilities.supports_vision);
        assert!(cmds.iter().any(|c| matches!(c, Cmd::PersistLastModel(_))));
    }

    #[test]
    fn build_chat_request_injects_memory_index() {
        let mut state = fresh_state();
        // No memory loaded → no memory block in the dynamic suffix.
        assert!(
            !build_chat_request(&state)
                .instructions
                .map(|i| i.contains("# Memory"))
                .unwrap_or(false)
        );
        // With memory → the auto-derived index is composed into the suffix.
        state.memory = Some(crate::app::memory::LoadedMemory {
            entries: Vec::new(),
            index: "# Memory\n\n## Global (all projects)\n- [pnpm] use pnpm — /m/pnpm.md\n"
                .to_string(),
            truncated: false,
        });
        let instr = build_chat_request(&state)
            .instructions
            .expect("memory index should populate the instructions suffix");
        assert!(instr.contains("# Memory"));
        assert!(instr.contains("[pnpm] use pnpm"));
    }

    #[test]
    fn build_chat_request_includes_current_working_directory() {
        let state = fresh_state();
        let request = build_chat_request(&state);
        assert!(request.system_prompt.contains("Current Session"));
        assert!(
            request
                .system_prompt
                .contains("Current working directory: /tmp/project")
        );
        assert!(
            request
                .system_prompt
                .contains("Treat this as the project root")
        );
        // The live safety mode must be surfaced so the model knows the current
        // policy instead of inferring it from a stale tool error.
        assert!(
            request.system_prompt.contains("Safety mode: "),
            "system prompt must surface the live safety mode"
        );
    }

    #[test]
    fn esc_during_turn_transitions_to_cancelling() {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(5));
        let msg = Msg::Key(Key {
            code: KeyCode::Escape,
            modifiers: KeyMods::default(),
        });
        let (state, cmds) = update(state, msg);
        assert!(matches!(
            state.turn,
            TurnState::Cancelling { id: TurnId(5), .. }
        ));
        assert!(
            cmds.iter()
                .any(|c| matches!(c, Cmd::CancelScope(TurnId(5))))
        );
    }

    #[test]
    fn esc_while_already_cancelling_forces_exit() {
        let mut state = fresh_state();
        state.turn = TurnState::Cancelling {
            id: TurnId(5),
            since: std::time::SystemTime::now(),
        };
        let msg = Msg::Key(Key {
            code: KeyCode::Escape,
            modifiers: KeyMods::default(),
        });
        let (state, cmds) = update(state, msg);
        assert!(state.should_exit);
        assert!(
            cmds.iter()
                .any(|c| matches!(c, Cmd::CancelScope(TurnId(5))))
        );
        assert!(cmds.iter().any(|c| matches!(c, Cmd::Exit)));
    }

    #[test]
    fn double_cancel_does_not_emit_twice() {
        let mut state = fresh_state();
        state.turn = TurnState::Cancelling {
            id: TurnId(1),
            since: std::time::SystemTime::now(),
        };
        let (_state, cmds) = update(state, Msg::CancelTurn);
        assert!(cmds.is_empty());
    }

    #[test]
    fn submit_prompt_on_idle_transitions_to_generating() {
        let state = fresh_state();
        let msg = Msg::SubmitPrompt {
            text: "hi there".to_string(),
            attachment_ids: vec![],
        };
        let (state, cmds) = update(state, msg);
        assert!(matches!(state.turn, TurnState::Generating { .. }));
        // F8: CallModel only — RefreshInstructions is now a synchronous
        // pre-flight inside handle_submit_prompt, so the Cmd stays
        // available for explicit refreshes but isn't emitted here.
        assert!(cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })));
        assert!(
            !cmds.iter().any(|c| matches!(c, Cmd::RefreshInstructions)),
            "F8: refresh happens inline; no longer queued as a Cmd",
        );
        // user message committed
        assert_eq!(state.session.messages().len(), 1);
        assert_eq!(state.session.messages()[0].content, "hi there");
    }

    #[test]
    fn submit_prompt_when_busy_is_dropped() {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(1));
        let msg = Msg::SubmitPrompt {
            text: "ignored".to_string(),
            attachment_ids: vec![],
        };
        let (state, cmds) = update(state, msg);
        assert!(matches!(
            state.turn,
            TurnState::Generating { id: TurnId(1), .. }
        ));
        assert!(cmds.is_empty());
        assert!(state.session.messages().is_empty());
    }

    #[test]
    fn cancelled_turn_submits_oldest_queued_message() {
        let mut state = fresh_state();
        state.turn = TurnState::Cancelling {
            id: TurnId(1),
            since: std::time::SystemTime::now(),
        };
        state
            .ui
            .queued_messages
            .push_back("first queued".to_string());
        state
            .ui
            .queued_messages
            .push_back("second queued".to_string());

        let (state, cmds) = update(state, Msg::TurnCancelled(TurnId(1)));

        assert!(matches!(state.turn, TurnState::Generating { .. }));
        assert!(cmds.iter().any(|cmd| matches!(cmd, Cmd::CallModel { .. })));
        assert_eq!(state.session.messages()[0].content, "first queued");
        assert_eq!(state.ui.queued_messages.len(), 1);
        assert_eq!(
            state.ui.queued_messages.front().map(String::as_str),
            Some("second queued")
        );
    }

    #[test]
    fn submit_prompt_trims_empty_input() {
        let state = fresh_state();
        let msg = Msg::SubmitPrompt {
            text: "   \n\t".to_string(),
            attachment_ids: vec![],
        };
        let (state, cmds) = update(state, msg);
        assert!(matches!(state.turn, TurnState::Idle));
        assert!(cmds.is_empty());
    }

    #[test]
    fn stale_stream_text_dropped_silently() {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(5));
        let msg = Msg::StreamText {
            turn: TurnId(4), // stale!
            chunk: "should be dropped".to_string(),
        };
        let (state, _cmds) = update(state, msg);
        if let TurnState::Generating { partial_text, .. } = &state.turn {
            assert!(partial_text.is_empty());
        } else {
            panic!("expected Generating");
        }
    }

    #[test]
    fn current_turn_stream_text_accumulates() {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(5));
        let (state, _) = update(
            state,
            Msg::StreamText {
                turn: TurnId(5),
                chunk: "hello ".to_string(),
            },
        );
        let (state, _) = update(
            state,
            Msg::StreamText {
                turn: TurnId(5),
                chunk: "world".to_string(),
            },
        );
        if let TurnState::Generating {
            partial_text,
            phase,
            ..
        } = &state.turn
        {
            assert_eq!(partial_text, "hello world");
            assert_eq!(*phase, GenPhase::Streaming);
        } else {
            panic!("expected Generating");
        }
    }

    #[test]
    fn reasoning_chunk_transitions_phase_to_thinking() {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(5));
        let (state, _) = update(
            state,
            Msg::StreamReasoning {
                turn: TurnId(5),
                chunk: crate::models::ReasoningChunk {
                    text: "weighing...".to_string(),
                    signature: None,
                },
            },
        );
        if let TurnState::Generating {
            phase,
            partial_reasoning,
            ..
        } = &state.turn
        {
            assert_eq!(*phase, GenPhase::Thinking);
            assert_eq!(partial_reasoning, "weighing...");
        } else {
            panic!("expected Generating");
        }
    }

    #[test]
    fn stream_done_commits_assistant_message_and_returns_to_idle() {
        let mut state = fresh_state();
        state.turn = TurnState::Generating {
            id: TurnId(5),
            started: std::time::SystemTime::now(),
            partial_text: "final answer".to_string(),
            partial_reasoning: String::new(),
            tokens: 0,
            phase: GenPhase::Streaming,
            thinking_signature: None,
            pending_tool_calls: Vec::new(),
        };
        let (state, cmds) = update(
            state,
            Msg::StreamDone {
                turn: TurnId(5),
                usage: None,
                thinking_signature: None,
            },
        );
        assert!(matches!(state.turn, TurnState::Idle));
        assert_eq!(state.session.messages().len(), 1);
        assert_eq!(state.session.messages()[0].content, "final answer");
        assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
    }

    #[test]
    fn stream_done_tracks_last_and_cumulative_token_usage() {
        let mut state = fresh_state();
        state.turn = TurnState::Generating {
            id: TurnId(5),
            started: std::time::SystemTime::now(),
            partial_text: "final answer".to_string(),
            partial_reasoning: String::new(),
            tokens: 0,
            phase: GenPhase::Streaming,
            thinking_signature: None,
            pending_tool_calls: Vec::new(),
        };

        let (state, _) = update(
            state,
            Msg::StreamDone {
                turn: TurnId(5),
                usage: Some(crate::models::TokenUsage::provider(120, 30, 150)),
                thinking_signature: None,
            },
        );

        assert_eq!(state.session.last_token_usage.unwrap().prompt_tokens, 120);
        assert_eq!(state.session.cumulative_token_usage.total_tokens, 150);
        assert_eq!(state.session.cumulative_tokens, 150);
        assert_eq!(
            state.session.context_usage.as_ref().unwrap().used_tokens,
            150
        );
    }

    #[test]
    fn context_usage_estimate_is_stored_during_generation() {
        let mut state = fresh_state();
        state.turn = TurnState::Generating {
            id: TurnId(5),
            started: std::time::SystemTime::now(),
            partial_text: String::new(),
            partial_reasoning: String::new(),
            tokens: 0,
            phase: GenPhase::Thinking,
            thinking_signature: None,
            pending_tool_calls: Vec::new(),
        };
        let snapshot = crate::domain::state::ContextUsageSnapshot::from_estimate(
            crate::domain::state::PromptTokenBreakdown {
                system_tokens: 10,
                instructions_tokens: 0,
                message_tokens: 20,
                tool_schema_tokens: 30,
                image_count: 0,
                message_count: 1,
                tool_count: 2,
            },
            Some(1_000),
        );

        let (state, _) = update(
            state,
            Msg::ContextUsageEstimated {
                turn: TurnId(5),
                snapshot,
            },
        );

        let context = state.session.context_usage.expect("context usage");
        assert!(context.is_estimate());
        assert_eq!(context.used_tokens, 60);
        assert_eq!(context.used_percent, Some(6));
    }

    #[test]
    fn context_text_explains_auto_compaction_policy() {
        let mut state = fresh_state();
        state.runtime.provider_capabilities.max_context_tokens = Some(8_000);
        state.session.append(ChatMessage::user("hello"));

        let text = context_text(&state);

        assert!(text.contains("Next request:"));
        assert!(text.contains("Response reserve:"));
        assert!(text.contains("Auto compact threshold:"));
        assert!(text.contains("Auto compact:"));
        assert!(text.contains("Hard limit risk:"));
    }

    /// F4 defense-in-depth: if a later refactor weakens the stale
    /// filter at the top of `update_step`, `handle_upstream_error`
    /// still refuses to mutate state when the error's turn id doesn't
    /// match the active turn. Direct-call the helper to exercise the
    /// guard without relying on the outer filter.
    #[test]
    fn handle_upstream_error_refuses_mismatched_turn_id() {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(5));
        let err = crate::models::UserFacingError {
            summary: "Stale".to_string(),
            message: "wrong turn".to_string(),
            suggestion: String::new(),
            category: crate::models::ErrorCategory::Temporary,
            recoverable: true,
        };
        super::handle_upstream_error(&mut state, TurnId(999), err);
        // Active turn must be untouched and no error message committed.
        assert!(matches!(
            state.turn,
            TurnState::Generating { id: TurnId(5), .. }
        ));
        assert!(state.session.messages().is_empty());
    }

    #[test]
    fn upstream_error_ends_turn_and_records_line() {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(1));
        let err = crate::models::UserFacingError {
            summary: "Server error".to_string(),
            message: "500 internal".to_string(),
            suggestion: "retry".to_string(),
            category: crate::models::ErrorCategory::Temporary,
            recoverable: true,
        };
        let (state, _) = update(
            state,
            Msg::UpstreamError {
                turn: TurnId(1),
                error: err,
            },
        );
        assert!(matches!(state.turn, TurnState::Idle));
        assert_eq!(state.session.messages().len(), 1);
        let m = &state.session.messages()[0];
        // Error surfaces through the ActionDisplay only — content is
        // intentionally empty so the chat widget doesn't paint the
        // error twice (once as a content line, once as an action).
        assert_eq!(m.content, "");
        assert_eq!(m.actions.len(), 1);
        assert_eq!(m.actions[0].target, "Server error");
    }

    #[test]
    fn slash_model_with_arg_persists_and_updates_session() {
        let state = fresh_state();
        let (state, cmds) = update(
            state,
            Msg::Slash(SlashCmd::Model(Some("anthropic/opus".to_string()))),
        );
        assert_eq!(state.session.model_id, "anthropic/opus");
        assert!(cmds.iter().any(|c| matches!(c, Cmd::PersistLastModel(_))));
        assert!(
            !cmds
                .iter()
                .any(|c| matches!(c, Cmd::PullOllamaModel { .. }))
        );
    }

    #[test]
    fn slash_model_local_ollama_auto_pulls() {
        let state = fresh_state();
        let (state, cmds) = update(
            state,
            Msg::Slash(SlashCmd::Model(Some("ollama/qwen3:8b".to_string()))),
        );
        assert_eq!(state.session.model_id, "ollama/qwen3:8b");
        assert!(
            cmds.iter()
                .any(|c| { matches!(c, Cmd::PullOllamaModel { model } if model == "qwen3:8b") }),
            "local Ollama model should dispatch pull: {:?}",
            cmds
        );
    }

    #[test]
    fn slash_model_bare_name_auto_pulls_as_ollama() {
        let state = fresh_state();
        let (_, cmds) = update(
            state,
            Msg::Slash(SlashCmd::Model(Some("qwen3-coder:30b".to_string()))),
        );
        assert!(
            cmds.iter().any(|c| {
                matches!(c, Cmd::PullOllamaModel { model } if model == "qwen3-coder:30b")
            }),
            "bare model names should dispatch an Ollama pull: {:?}",
            cmds
        );
    }

    #[test]
    fn slash_model_ollama_cloud_skips_local_pull() {
        let state = fresh_state();
        let (_, cmds) = update(
            state,
            Msg::Slash(SlashCmd::Model(Some("ollama/gpt-oss:cloud".to_string()))),
        );
        assert!(
            !cmds
                .iter()
                .any(|c| matches!(c, Cmd::PullOllamaModel { .. }))
        );
    }

    #[test]
    fn slash_help_appends_system_help_and_persists() {
        let state = fresh_state();
        let (state, cmds) = update(state, Msg::Slash(SlashCmd::Help));
        let msg = state.session.messages().last().expect("help message");
        assert_eq!(msg.role, MessageRole::System);
        assert!(msg.content.contains("Everyday:"));
        assert!(msg.content.contains("Advanced runtime:"));
        assert!(msg.content.contains("/model"));
        assert!(msg.content.contains("/help"));
        assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
    }

    #[test]
    fn slash_doctor_appends_session_readiness_report() {
        let state = fresh_state();
        let (state, cmds) = update(state, Msg::Slash(SlashCmd::Doctor));
        let msg = state.session.messages().last().expect("doctor message");
        assert_eq!(msg.role, MessageRole::System);
        assert!(msg.content.contains("Mermaid Doctor"));
        assert!(msg.content.contains("Active model:"));
        assert!(msg.content.contains("Safety:"));
        assert!(cmds.iter().any(|c| matches!(c, Cmd::SaveConversation(_))));
    }

    #[test]
    fn slash_memory_commands_dispatch_effects() {
        // /memory lists; /remember <text> and /forget <id> route to effects.
        let (_s, cmds) = update(fresh_state(), Msg::Slash(SlashCmd::Memory));
        assert!(cmds.iter().any(|c| matches!(c, Cmd::ListMemory)));

        let (_s, cmds) = update(
            fresh_state(),
            Msg::Slash(SlashCmd::Remember(Some("prefer ripgrep".to_string()))),
        );
        assert!(
            cmds.iter()
                .any(|c| matches!(c, Cmd::RememberMemory { text } if text == "prefer ripgrep"))
        );

        let (_s, cmds) = update(
            fresh_state(),
            Msg::Slash(SlashCmd::Forget(Some("prefer-rg".to_string()))),
        );
        assert!(
            cmds.iter()
                .any(|c| matches!(c, Cmd::ForgetMemory { id } if id == "prefer-rg"))
        );

        // No-arg /remember explains usage instead of dispatching.
        let (state, cmds) = update(fresh_state(), Msg::Slash(SlashCmd::Remember(None)));
        assert!(!cmds.iter().any(|c| matches!(c, Cmd::RememberMemory { .. })));
        assert!(state.status.is_some());

        // /consolidate-memory routes to the model-assisted prune effect.
        let (_s, cmds) = update(fresh_state(), Msg::Slash(SlashCmd::ConsolidateMemory));
        assert!(
            cmds.iter()
                .any(|c| matches!(c, Cmd::ConsolidateMemory { .. }))
        );
    }

    #[test]
    fn chat_request_uses_runtime_prompt_customization() {
        let mut state = fresh_state();
        state.settings.prompt.system_prompt = Some("replacement prompt".to_string());
        state
            .settings
            .prompt
            .append_system_prompt
            .push("extra runtime rule".to_string());

        let request = build_chat_request(&state);
        assert!(request.system_prompt.contains("replacement prompt"));
        assert!(request.system_prompt.contains("extra runtime rule"));
        assert!(!request.system_prompt.contains("Core Loop"));
        assert!(request.system_prompt.contains("Current working directory"));
    }

    #[test]
    fn slash_reasoning_persists_per_model() {
        let state = fresh_state();
        let (state, cmds) = update(
            state,
            Msg::Slash(SlashCmd::Reasoning(Some(
                crate::models::ReasoningLevel::High,
            ))),
        );
        assert_eq!(state.session.reasoning, crate::models::ReasoningLevel::High);
        let emitted = cmds
            .iter()
            .find_map(|c| match c {
                Cmd::PersistReasoningFor { model_id, level } => Some((model_id.clone(), *level)),
                _ => None,
            })
            .expect("persist cmd emitted");
        assert_eq!(emitted.0, "ollama/test");
        assert_eq!(emitted.1, crate::models::ReasoningLevel::High);
    }

    #[test]
    fn slash_visible_reasoning_toggles_runtime_ui_state() {
        let state = fresh_state();
        let (state, _) = update(state, Msg::Slash(SlashCmd::VisibleReasoning(None)));
        assert!(state.ui.show_reasoning);

        let (state, _) = update(
            state,
            Msg::Slash(SlashCmd::VisibleReasoning(Some("off".to_string()))),
        );
        assert!(!state.ui.show_reasoning);
    }

    #[test]
    fn cycle_safety_walks_by_permissiveness() {
        use crate::runtime::SafetyMode as S;
        assert_eq!(cycle_safety(S::ReadOnly), S::Ask);
        assert_eq!(cycle_safety(S::Ask), S::Auto);
        assert_eq!(cycle_safety(S::Auto), S::FullAccess);
        assert_eq!(cycle_safety(S::FullAccess), S::ReadOnly);
    }

    #[test]
    fn shift_tab_cycles_safety_mode() {
        let state = fresh_state();
        let start = state.session.safety_mode;
        let (state, _) = update(
            state,
            Msg::Key(Key {
                code: KeyCode::BackTab,
                modifiers: KeyMods::NONE,
            }),
        );
        assert_eq!(state.session.safety_mode, cycle_safety(start));
    }

    #[test]
    fn slash_safety_sets_session_mode() {
        let state = fresh_state();
        let (state, _) = update(
            state,
            Msg::Slash(SlashCmd::Safety(Some(crate::runtime::SafetyMode::Auto))),
        );
        assert_eq!(state.session.safety_mode, crate::runtime::SafetyMode::Auto);
    }

    /// State with one queued approval (turn must accept the message, so put
    /// the reducer in a live turn first).
    fn pending_approval_state() -> State {
        let mut state = fresh_state();
        state.turn = start_generating(TurnId(1));
        let (state, _) = update(
            state,
            Msg::ApprovalRequested {
                turn: TurnId(1),
                call_id: super::super::ids::ToolCallId(5),
                tool: "execute_command".to_string(),
                risk: "shell_mutation".to_string(),
                kind: crate::domain::ApprovalKind::Shell,
                prompt: "$ npm test".to_string(),
                allowlist_scope: "execute_command:npm".to_string(),
            },
        );
        state
    }

    fn key(code: KeyCode) -> Msg {
        Msg::Key(Key {
            code,
            modifiers: KeyMods::NONE,
        })
    }

    #[test]
    fn paste_interleaved_with_keys_preserves_order() {
        // Reproduces the Windows paste scramble: a paste burst splits into
        // stray Char keys + coalesced Paste chunks. Both must insert at the
        // cursor and advance it, so the result stays in order regardless of
        // the split. (Before the fix, Paste appended to the end while keys
        // inserted at a never-advanced cursor, yielding "RDeview the Docs".)
        let mut state = fresh_state();
        for msg in [
            key(KeyCode::Char('R')),
            Msg::Paste(Paste::Text("eview the ".to_string())),
            key(KeyCode::Char('D')),
            Msg::Paste(Paste::Text("ocs".to_string())),
        ] {
            let (next, _) = update(state, msg);
            state = next;
        }
        assert_eq!(state.ui.input_buffer, "Review the Docs");
        assert_eq!(state.ui.input_cursor, state.ui.input_buffer.len());
    }

    #[test]
    fn paste_inserts_at_cursor_not_end() {
        // Type "ac", move left one, paste "b" → "abc" (not "acb").
        let mut state = fresh_state();
        for msg in [
            key(KeyCode::Char('a')),
            key(KeyCode::Char('c')),
            key(KeyCode::Left),
            Msg::Paste(Paste::Text("b".to_string())),
        ] {
            let (next, _) = update(state, msg);
            state = next;
        }
        assert_eq!(state.ui.input_buffer, "abc");
    }

    #[test]
    fn approval_requested_enqueues_modal() {
        let state = pending_approval_state();
        assert_eq!(state.pending_approval.len(), 1);
        assert_eq!(
            state.pending_approval.front().unwrap().tool,
            "execute_command"
        );
    }

    #[test]
    fn approval_keys_emit_the_right_decision() {
        use crate::domain::ApprovalChoice as A;
        for (code, expected) in [
            (KeyCode::Char('1'), A::Approve),
            (KeyCode::Char('y'), A::Approve),
            (KeyCode::Enter, A::Approve),
            (KeyCode::Char('2'), A::ApproveAlways),
            (KeyCode::Char('a'), A::ApproveAlways),
            (KeyCode::Char('3'), A::Deny),
            (KeyCode::Char('n'), A::Deny),
            (KeyCode::Escape, A::Deny),
        ] {
            let (state, cmds) = update(pending_approval_state(), key(code));
            assert!(
                state.pending_approval.is_empty(),
                "{code:?} should pop the modal"
            );
            assert!(
                cmds.iter().any(
                    |c| matches!(c, Cmd::ResolveApproval { decision, .. } if *decision == expected)
                ),
                "{code:?} should resolve {expected:?}; got {cmds:?}",
            );
            // Esc on an approval denies the tool — it must NOT cancel the turn.
            if code == KeyCode::Escape {
                assert!(
                    !cmds.iter().any(|c| matches!(c, Cmd::CancelScope(_))),
                    "Esc on an approval must deny, not cancel the turn",
                );
            }
        }
    }

    #[test]
    fn approval_modal_swallows_unrelated_keys() {
        let (state, cmds) = update(pending_approval_state(), key(KeyCode::Char('x')));
        assert_eq!(
            state.pending_approval.len(),
            1,
            "unrelated key must not pop the modal"
        );
        assert!(cmds.is_empty());
    }

    #[test]
    fn approval_arrows_move_highlight_without_resolving() {
        // ↓ moves the highlight and clamps at the last option; ↑ moves back.
        // Neither resolves the modal.
        let (state, cmds) = update(pending_approval_state(), key(KeyCode::Down));
        assert_eq!(state.pending_approval.front().unwrap().selected_option, 1);
        assert!(cmds.is_empty() && state.pending_approval.len() == 1);

        let (state, _) = update(state, key(KeyCode::Down));
        assert_eq!(state.pending_approval.front().unwrap().selected_option, 2);
        let (state, _) = update(state, key(KeyCode::Down)); // clamps at 2
        assert_eq!(state.pending_approval.front().unwrap().selected_option, 2);
        let (state, _) = update(state, key(KeyCode::Up));
        assert_eq!(state.pending_approval.front().unwrap().selected_option, 1);
    }

    #[test]
    fn approval_enter_resolves_the_highlighted_option() {
        use crate::domain::ApprovalChoice as A;
        // Highlight option 3 (No) with two ↓, then Enter → Deny.
        let (state, _) = update(pending_approval_state(), key(KeyCode::Down));
        let (state, _) = update(state, key(KeyCode::Down));
        let (state, cmds) = update(state, key(KeyCode::Enter));
        assert!(
            state.pending_approval.is_empty(),
            "Enter should pop the modal"
        );
        assert!(
            cmds.iter().any(
                |c| matches!(c, Cmd::ResolveApproval { decision, .. } if *decision == A::Deny)
            ),
            "Enter on the highlighted 'No' must deny; got {cmds:?}"
        );
    }

    #[test]
    fn approval_fifo_shows_one_at_a_time() {
        let state = pending_approval_state();
        let (state, _) = update(
            state,
            Msg::ApprovalRequested {
                turn: TurnId(1),
                call_id: super::super::ids::ToolCallId(6),
                tool: "write_file".to_string(),
                risk: "file_mutation".to_string(),
                kind: crate::domain::ApprovalKind::FileMutation,
                prompt: "src/x.rs".to_string(),
                allowlist_scope: "write_file".to_string(),
            },
        );
        assert_eq!(state.pending_approval.len(), 2);
        let (state, _) = update(state, key(KeyCode::Char('1')));
        assert_eq!(state.pending_approval.len(), 1);
        assert_eq!(state.pending_approval.front().unwrap().tool, "write_file");
    }

    #[test]
    fn clear_confirm_now_accepts_via_keypress() {
        // Regression: the /clear confirmation was inert (never rendered, never
        // key-handled). It now resolves on a keypress.
        let mut state = fresh_state();
        state.confirm = Some(super::super::state::Confirmation {
            prompt: "Clear conversation history?".to_string(),
            accept_msg_token: super::super::state::ConfirmationTarget::ClearConversation,
        });
        let (state, _) = update(state, key(KeyCode::Char('y')));
        assert!(
            state.confirm.is_none(),
            "y should accept and clear the confirm modal"
        );
    }

    #[test]
    fn slash_clear_raises_confirmation() {
        let state = fresh_state();
        let (state, _) = update(state, Msg::Slash(SlashCmd::Clear));
        assert!(state.confirm.is_some());
    }

    #[test]
    fn confirm_accepted_for_clear_wipes_messages() {
        let mut state = fresh_state();
        state.session.append(ChatMessage::user("one"));
        state.session.append(ChatMessage::assistant("two"));
        state.confirm = Some(super::super::state::Confirmation {
            prompt: "Clear conversation history?".to_string(),
            accept_msg_token: super::super::state::ConfirmationTarget::ClearConversation,
        });
        let (state, _) = update(state, Msg::ConfirmAccepted);
        assert!(state.session.messages().is_empty());
        assert!(state.confirm.is_none());
    }

    #[test]
    fn confirm_declined_clears_without_action() {
        let mut state = fresh_state();
        state.session.append(ChatMessage::user("kept"));
        state.confirm = Some(super::super::state::Confirmation {
            prompt: "Clear conversation history?".to_string(),
            accept_msg_token: super::super::state::ConfirmationTarget::ClearConversation,
        });
        let (state, _) = update(state, Msg::ConfirmDeclined);
        assert_eq!(state.session.messages().len(), 1);
        assert!(state.confirm.is_none());
    }

    #[test]
    fn mcp_server_ready_updates_entry_status() {
        let mut state = fresh_state();
        state.mcp = McpState::default();
        state.mcp.servers.insert(
            "s1".to_string(),
            McpServerEntry {
                config: crate::app::McpServerConfig {
                    command: "echo".to_string(),
                    args: vec![],
                    env: std::collections::HashMap::new(),
                },
                status: McpServerStatus::Starting,
                tools: vec![],
            },
        );
        let (state, _) = update(
            state,
            Msg::McpServerReady {
                name: "s1".to_string(),
                tools: vec![],
            },
        );
        assert_eq!(state.mcp.servers["s1"].status, McpServerStatus::Ready);
    }

    #[test]
    fn mcp_server_errored_sets_status_and_emits_status_line() {
        let mut state = fresh_state();
        state.mcp.servers.insert(
            "s1".to_string(),
            McpServerEntry {
                config: crate::app::McpServerConfig {
                    command: "echo".to_string(),
                    args: vec![],
                    env: std::collections::HashMap::new(),
                },
                status: McpServerStatus::Starting,
                tools: vec![],
            },
        );
        let (state, _) = update(
            state,
            Msg::McpServerErrored {
                name: "s1".to_string(),
                reason: "exit 1".to_string(),
            },
        );
        match &state.mcp.servers["s1"].status {
            McpServerStatus::Errored { reason } => assert_eq!(reason, "exit 1"),
            _ => panic!("expected Errored"),
        }
        assert!(state.status.is_some());
    }

    #[test]
    fn status_dismiss_clears_status_line() {
        let mut state = fresh_state();
        state.status = Some(StatusLine {
            text: "info".to_string(),
            kind: StatusKind::Info,
            shown_at: std::time::SystemTime::now(),
        });
        let (state, _) = update(state, Msg::StatusDismiss);
        assert!(state.status.is_none());
    }

    #[test]
    fn tool_finished_with_all_outcomes_triggers_follow_up_call_model() {
        let mut state = fresh_state();
        let call = PendingToolCall {
            call_id: super::super::ids::ToolCallId(1),
            source: crate::models::tool_call::ToolCall {
                id: Some("c1".to_string()),
                function: crate::models::tool_call::FunctionCall {
                    name: "read_file".to_string(),
                    arguments: serde_json::json!({"path": "foo"}),
                },
            },
        };
        state.turn = start_executing_tools(TurnId(3), vec![call]);
        // The reducer looks up the "last assistant message" to attach
        // an ActionDisplay — plant one so the lookup doesn't silently
        // no-op in this test.
        state.session.append(ChatMessage::assistant("tools follow"));

        let (state, cmds) = update(
            state,
            Msg::ToolFinished {
                turn: TurnId(3),
                call_id: super::super::ids::ToolCallId(1),
                outcome: ToolOutcome::success("file contents", "file contents", 0.05),
            },
        );

        assert!(matches!(state.turn, TurnState::Generating { .. }));
        assert!(cmds.iter().any(|c| matches!(c, Cmd::CallModel { .. })));
        // Tool result message was appended.
        let last = state.session.messages().last().unwrap();
        assert_eq!(last.role, MessageRole::Tool);
    }

    #[test]
    fn background_command_tool_finish_registers_process() {
        let mut state = fresh_state();
        let call = PendingToolCall {
            call_id: super::super::ids::ToolCallId(1),
            source: crate::models::tool_call::ToolCall {
                id: Some("c1".to_string()),
                function: crate::models::tool_call::FunctionCall {
                    name: "execute_command".to_string(),
                    arguments: serde_json::json!({
                        "command": "npm run dev",
                        "mode": "background",
                        "working_dir": "/tmp/project",
                    }),
                },
            },
        };
        state.turn = start_executing_tools(TurnId(3), vec![call]);
        state.session.append(ChatMessage::assistant("tools follow"));

        let (state, _) = update(
            state,
            Msg::ToolFinished {
                turn: TurnId(3),
                call_id: super::super::ids::ToolCallId(1),
                outcome: ToolOutcome::success(
                    "Background command started.\nPID: 123\nLog: /tmp/mermaid-bg.log\nReady: matched pattern \"Local:\"\nDetected URL: http://127.0.0.1:5173\n",
                    "background process started",
                    0.2,
                )
                .with_metadata(crate::domain::ToolRunMetadata {
                    process: Some(crate::domain::ManagedProcess {
                        id: "bg-123".to_string(),
                        pid: 123,
                        command: "npm run dev".to_string(),
                        cwd: Some("/tmp/project".to_string()),
                        log_path: "/tmp/mermaid-bg.log".to_string(),
                        detected_url: Some("http://127.0.0.1:5173".to_string()),
                        status: crate::domain::ManagedProcessStatus::Running,
                    }),
                    ..crate::domain::ToolRunMetadata::default()
                }),
            },
        );

        assert_eq!(state.runtime.processes.len(), 1);
        let process = &state.runtime.processes[0];
        assert_eq!(process.pid, 123);
        assert_eq!(process.command, "npm run dev");
        assert_eq!(process.cwd.as_deref(), Some("/tmp/project"));
        assert_eq!(
            process.detected_url.as_deref(),
            Some("http://127.0.0.1:5173")
        );
    }

    #[test]
    fn tool_finished_partial_stays_in_executing() {
        let mut state = fresh_state();
        let calls = vec![
            PendingToolCall {
                call_id: super::super::ids::ToolCallId(1),
                source: crate::models::tool_call::ToolCall {
                    id: Some("c1".to_string()),
                    function: crate::models::tool_call::FunctionCall {
                        name: "read_file".to_string(),
                        arguments: serde_json::json!({}),
                    },
                },
            },
            PendingToolCall {
                call_id: super::super::ids::ToolCallId(2),
                source: crate::models::tool_call::ToolCall {
                    id: Some("c2".to_string()),
                    function: crate::models::tool_call::FunctionCall {
                        name: "write_file".to_string(),
                        arguments: serde_json::json!({}),
                    },
                },
            },
        ];
        state.turn = start_executing_tools(TurnId(3), calls);
        state.session.append(ChatMessage::assistant("tools follow"));

        let (state, cmds) = update(
            state,
            Msg::ToolFinished {
                turn: TurnId(3),
                call_id: super::super::ids::ToolCallId(1),
                outcome: ToolOutcome::cancelled(),
            },
        );

        // Still in ExecutingTools (second tool pending).
        match &state.turn {
            TurnState::ExecutingTools { outcomes, .. } => {
                assert_eq!(outcomes.len(), 2);
                assert!(outcomes[0].is_some());
                assert!(outcomes[1].is_none());
            },
            _ => panic!("should still be ExecutingTools"),
        }
        assert!(cmds.is_empty());
    }

    #[test]
    fn stale_tool_finished_dropped_silently() {
        let mut state = fresh_state();
        state.turn = start_executing_tools(
            TurnId(3),
            vec![PendingToolCall {
                call_id: super::super::ids::ToolCallId(1),
                source: crate::models::tool_call::ToolCall {
                    id: None,
                    function: crate::models::tool_call::FunctionCall {
                        name: "x".to_string(),
                        arguments: serde_json::json!({}),
                    },
                },
            }],
        );

        let (state, cmds) = update(
            state,
            Msg::ToolFinished {
                turn: TurnId(999),
                call_id: super::super::ids::ToolCallId(1),
                outcome: ToolOutcome::cancelled(),
            },
        );
        match &state.turn {
            TurnState::ExecutingTools { outcomes, .. } => {
                assert!(outcomes[0].is_none());
            },
            _ => panic!("unchanged state expected"),
        }
        assert!(cmds.is_empty());
    }

    #[test]
    fn tick_is_noop() {
        let before = fresh_state();
        let (after, cmds) = update(before.clone(), Msg::Tick);
        assert!(cmds.is_empty());
        assert!(matches!(after.turn, TurnState::Idle));
    }

    #[test]
    fn resize_is_noop() {
        let (state, cmds) = update(
            fresh_state(),
            Msg::Resize {
                width: 80,
                height: 24,
            },
        );
        assert!(cmds.is_empty());
        assert!(matches!(state.turn, TurnState::Idle));
    }

    #[test]
    fn ui_state_default_is_empty() {
        let s = UiState::default();
        assert!(s.input_buffer.is_empty());
        assert_eq!(s.chat_scroll, 0);
        assert!(matches!(s.mode, UiMode::EditingInput));
    }
}
