//! Pure MVU update function. Each call consumes one event and may produce
//! zero or more commands for the runtime to dispatch to the agent layer.

use capo_agent::{
    settings::UiSettings, Command, Decision, PermissionChoice, PermissionResolution, UiEvent,
};

use crate::events::{Event, InputEvent};
use crate::state::{
    AppState, ApprovalOption, ApprovalState, BottomPane, InFlight, MessageBlock, PendingTurnSubmit,
};
use crate::widgets::messages::{is_collapsed_view, is_visible};

/// Apply `event` to `state` and return any intents.
pub fn update(state: &mut AppState, event: Event) -> Vec<Command> {
    match event {
        Event::Input(ie) => handle_input(state, ie),
        Event::Ui(ue) => handle_ui(state, ue),
    }
}

fn handle_input(state: &mut AppState, event: InputEvent) -> Vec<Command> {
    use crossterm::event::{KeyCode, KeyModifiers};

    let key = match event {
        InputEvent::Key(k) => {
            // v0.9 Phase A: Ctrl+E is universal — works from editor and
            // any BottomPane. Pure display state, no Command emit.
            if k.code == KeyCode::Char('e') && k.modifiers.contains(KeyModifiers::CONTROL) {
                state.expanded_all = !state.expanded_all;
                normalize_nav_selection(state);
                return Vec::new();
            }
            // v0.10 Phase A: Shift+Tab cycles permission mode. Emits
            // Command::SetPermissionMode; state updates when
            // UiEvent::PermissionModeChanged arrives back. Ignored when a
            // BottomPane is open (pane keys take priority per spec §3.6).
            if k.code == KeyCode::Tab
                && k.modifiers.contains(KeyModifiers::SHIFT)
                && state.bottom_pane.is_none()
            {
                let next = capo_agent::next_mode(state.permission_mode);
                return vec![capo_agent::Command::SetPermissionMode { mode: next }];
            }
            if k.code == KeyCode::F(3) && state.bottom_pane.is_none() {
                if state.nav_mode {
                    state.nav_mode = false;
                    state.nav_g_pending_at = None;
                } else {
                    let ui_settings = state
                        .settings_snapshot
                        .as_ref()
                        .map(|s| s.ui.clone())
                        .unwrap_or_default();
                    state.nav_mode = true;
                    state.nav_selected =
                        last_visible_index(&state.messages, &ui_settings, state.expanded_all);
                }
                return Vec::new();
            }
            if k.code == KeyCode::Char('c')
                && k.modifiers == KeyModifiers::CONTROL
                && matches!(
                    state.bottom_pane,
                    Some(
                        BottomPane::Palette(_)
                            | BottomPane::FilePicker(_)
                            | BottomPane::ModelPicker(_)
                            | BottomPane::ResumePicker(_)
                            | BottomPane::ForkPicker(_)
                            | BottomPane::BranchTree(_)
                            | BottomPane::Settings(_)
                    )
                )
            {
                let was_palette = matches!(state.bottom_pane, Some(BottomPane::Palette(_)));
                state.bottom_pane = None;
                return if was_palette {
                    handle_ctrl_c(state)
                } else {
                    Vec::new()
                };
            }
            if state.bottom_pane.is_some() {
                return handle_bottom_pane_key(state, k);
            }
            if state.nav_mode {
                return handle_nav_key(state, k);
            }
            k
        }
        InputEvent::Paste(text) => {
            if state.bottom_pane.is_some() || state.nav_mode {
                return Vec::new();
            }
            state.editor.text.push_str(&text);
            state.editor.cursor_byte = state.editor.text.len();
            return Vec::new();
        }
        InputEvent::Resize(_, _) => return Vec::new(),
        InputEvent::Tick => return Vec::new(),
    };

    match (key.code, key.modifiers) {
        (KeyCode::Enter, KeyModifiers::SHIFT) => {
            state.editor.text.push('\n');
            state.editor.cursor_byte = state.editor.text.len();
            Vec::new()
        }
        (KeyCode::Enter, _) => {
            if state.in_flight.is_some() {
                return Vec::new();
            }
            let text = std::mem::take(&mut state.editor.text);
            let composer_text = text.clone();
            state.editor.cursor_byte = 0;
            if text.trim().is_empty() {
                return Vec::new();
            }
            // `!!cmd` / `!cmd` — inline bash. Existing behaviour, before
            // attachment parsing.
            if let Some(rest) = text.strip_prefix("!!") {
                let command = rest.trim().to_string();
                if !command.is_empty() {
                    return vec![Command::RunInlineBash {
                        command,
                        send_to_llm: false,
                    }];
                }
            } else if let Some(rest) = text.strip_prefix('!') {
                let command = rest.trim().to_string();
                if !command.is_empty() {
                    return vec![Command::RunInlineBash {
                        command,
                        send_to_llm: true,
                    }];
                }
            }

            // Parse for /image or auto-detect attachments. Existing slash
            // commands and plain text fall through unchanged.
            use crate::composer_parser::{parse_composer_submit, ParsedSubmit};
            let parsed = parse_composer_submit(&text);

            let (msg_text, attachments) = match parsed {
                ParsedSubmit::SlashImage { attachments, text } => (text, attachments),
                ParsedSubmit::Plain { attachments, text } => (text, attachments),
                ParsedSubmit::SlashImageMalformed => {
                    state.messages.push(MessageBlock::Notice {
                        title: "/image".into(),
                        body:
                            "/image requires at least one path token (png, jpg, jpeg, gif, webp)."
                                .into(),
                    });
                    // Restore the buffer so the user can edit.
                    state.editor.text = text;
                    state.editor.cursor_byte = state.editor.text.len();
                    return Vec::new();
                }
                ParsedSubmit::OtherSlashCommand => {
                    // Existing slash-command path. The original code at
                    // this point didn't have any special branch — just
                    // built the SendUserMessage. Keep that behaviour.
                    (text, Vec::new())
                }
            };

            // Convert PathBuf attachments into capo-agent::Attachment.
            let cmd_attachments: Vec<capo_agent::Attachment> = attachments
                .iter()
                .map(|p| capo_agent::Attachment::Image { path: p.clone() })
                .collect();

            // Transcript filenames for the user-message cell.
            let attachment_filenames: Vec<String> = attachments
                .iter()
                .filter_map(|p| {
                    p.file_name()
                        .and_then(|s| s.to_str())
                        .map(|s| s.to_string())
                })
                .collect();

            // A pending fork: this message branches instead of continuing.
            if let Some(from) = state.pending_fork.take() {
                // Phase C does not extend ForkFrom with attachments;
                // attachment support for /fork lands later. For now,
                // attachments are dropped on the fork path, so the
                // optimistic transcript must not claim they were sent.
                let displayed_filenames = if cmd_attachments.is_empty() {
                    attachment_filenames
                } else {
                    Vec::new()
                };
                state.push_user_message(msg_text.clone(), displayed_filenames);
                state.in_flight = Some(InFlight::Thinking);
                return vec![Command::ForkFrom {
                    from,
                    message: msg_text,
                }];
            }

            let message_index = state.messages.len();
            state.push_user_message(msg_text.clone(), attachment_filenames);
            state.pending_turn_submit = Some(PendingTurnSubmit {
                composer_text,
                message_index,
            });
            state.in_flight = Some(InFlight::Thinking);
            vec![Command::SendUserMessage {
                text: msg_text,
                attachments: cmd_attachments,
            }]
        }
        (KeyCode::Esc, _) => {
            if state.in_flight.is_some() {
                vec![Command::CancelAgent]
            } else {
                state.editor.text.clear();
                state.editor.cursor_byte = 0;
                Vec::new()
            }
        }
        (KeyCode::Char('c'), KeyModifiers::CONTROL) => handle_ctrl_c(state),
        // Transcript scrollback — relative-to-bottom offset.
        // Half-page step (10 rows) matches codex's `pager_overlay` cadence
        // and keeps the user's last-read region anchored across PgUp/PgDn.
        (KeyCode::PageUp, _) => {
            state.scroll_lines_from_bottom = state.scroll_lines_from_bottom.saturating_add(10);
            Vec::new()
        }
        (KeyCode::PageDown, _) => {
            state.scroll_lines_from_bottom = state.scroll_lines_from_bottom.saturating_sub(10);
            Vec::new()
        }
        // Up/Down arrows scroll one row at a time. The mouse wheel reaches
        // us as these keys via DECSET 1007 alt-scroll (set by
        // `TerminalGuard::init`), so wheel + arrows share the same path.
        // Three rows per notch would feel jumpy because the terminal
        // typically emits one Up/Down per wheel detent; stay at 1.
        (KeyCode::Up, _) => {
            state.scroll_lines_from_bottom = state.scroll_lines_from_bottom.saturating_add(1);
            Vec::new()
        }
        (KeyCode::Down, _) => {
            state.scroll_lines_from_bottom = state.scroll_lines_from_bottom.saturating_sub(1);
            Vec::new()
        }
        (KeyCode::Home, _) => {
            // Large sentinel; render saturating-subs against `bottom` so
            // the viewport clamps to the top regardless of actual transcript
            // height.
            state.scroll_lines_from_bottom = u16::MAX;
            Vec::new()
        }
        (KeyCode::End, _) => {
            state.scroll_lines_from_bottom = 0;
            Vec::new()
        }
        (KeyCode::Char('@'), _) if state.in_flight.is_none() => {
            let anchor = state.editor.text.len();
            let mut fp = crate::state::FilePickerState {
                anchor_byte: anchor,
                ..Default::default()
            };
            fp.matches = state.file_index.filter("", 8);
            state.bottom_pane = Some(BottomPane::FilePicker(fp));
            Vec::new()
        }
        (KeyCode::Char('/'), _) if state.editor.text.is_empty() && state.in_flight.is_none() => {
            state.bottom_pane = Some(BottomPane::Palette(crate::slash::PaletteState::default()));
            Vec::new()
        }
        (KeyCode::Char('/'), _) if state.editor.text.is_empty() => Vec::new(),
        (KeyCode::Char(c), _) => {
            state.editor.text.push(c);
            state.editor.cursor_byte = state.editor.text.len();
            Vec::new()
        }
        (KeyCode::Backspace, _) => {
            state.editor.text.pop();
            state.editor.cursor_byte = state.editor.text.len();
            Vec::new()
        }
        _ => Vec::new(),
    }
}

fn handle_ctrl_c(state: &mut AppState) -> Vec<Command> {
    let now = std::time::Instant::now();
    let within_window = state
        .last_ctrl_c
        .map(|t| now.duration_since(t) < std::time::Duration::from_secs(2))
        .unwrap_or(false);
    if within_window {
        state.last_ctrl_c = None;
        state.should_quit = true;
        vec![Command::Quit]
    } else {
        state.last_ctrl_c = Some(now);
        state.editor.text.clear();
        state.editor.cursor_byte = 0;
        Vec::new()
    }
}

/// Find the last visible block's index in `messages`. None if all blocks
/// are hidden (e.g. all Thinking with hide_thinking_blocks=true) or empty.
/// In Task 3 this returns `messages.len().checked_sub(1)` — Task 7 wires
/// `is_visible` into it once the skip-hidden logic is needed.
fn last_visible_index(
    messages: &[MessageBlock],
    settings: &UiSettings,
    expanded_all: bool,
) -> Option<usize> {
    messages
        .iter()
        .enumerate()
        .rev()
        .find(|(_, b)| is_visible(b, settings, expanded_all))
        .map(|(i, _)| i)
}

/// Find the first visible block's index in `messages`. None if empty.
fn first_visible_index(
    messages: &[MessageBlock],
    settings: &UiSettings,
    expanded_all: bool,
) -> Option<usize> {
    messages
        .iter()
        .position(|b| is_visible(b, settings, expanded_all))
}

fn maybe_scroll_into_view(state: &mut AppState) {
    let Some((width, height)) = state.last_messages_viewport.get() else {
        return;
    };
    if height == 0 {
        return;
    }
    let Some(selected_idx) = state.nav_selected else {
        return;
    };
    let Some(selected_block) = state.messages.get(selected_idx) else {
        return;
    };

    let ui_settings = state
        .settings_snapshot
        .as_ref()
        .map(|s| s.ui.clone())
        .unwrap_or_default();
    let expanded_all = state.expanded_all;
    let rows_below_selected = crate::widgets::messages::row_position_from_bottom(
        &state.messages,
        selected_idx,
        &ui_settings,
        expanded_all,
        width,
    );
    let selected_rows = crate::widgets::messages::block_wrap_row_count(
        selected_block,
        selected_idx,
        state.messages.len(),
        &ui_settings,
        expanded_all,
        width,
    );
    if selected_rows == 0 {
        return;
    }

    let current_offset = state.scroll_lines_from_bottom;
    let selected_top_from_bottom =
        rows_below_selected.saturating_add(selected_rows.saturating_sub(1));

    let fully_on_screen = rows_below_selected >= current_offset
        && selected_top_from_bottom < current_offset.saturating_add(height);
    if fully_on_screen {
        return;
    }

    state.scroll_lines_from_bottom = rows_below_selected
        .saturating_add(selected_rows)
        .saturating_sub(height);
}

fn handle_nav_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crossterm::event::KeyCode;

    let ui_settings = state
        .settings_snapshot
        .as_ref()
        .map(|s| s.ui.clone())
        .unwrap_or_default();
    let expanded_all = state.expanded_all;

    match key.code {
        KeyCode::Up | KeyCode::Char('k') => {
            if let Some(current) = state.nav_selected {
                let prev = prev_visible_index(&state.messages, current, &ui_settings, expanded_all);
                state.nav_selected = Some(prev);
                maybe_scroll_into_view(state);
            }
            state.nav_g_pending_at = None;
            Vec::new()
        }
        KeyCode::Down | KeyCode::Char('j') => {
            if let Some(current) = state.nav_selected {
                let next = next_visible_index(&state.messages, current, &ui_settings, expanded_all);
                state.nav_selected = Some(next);
                maybe_scroll_into_view(state);
            }
            state.nav_g_pending_at = None;
            Vec::new()
        }
        KeyCode::Esc => {
            state.nav_mode = false;
            state.nav_g_pending_at = None;
            Vec::new()
        }
        KeyCode::Char(' ') => {
            if let Some(idx) = state.nav_selected {
                if let Some(block) = state.messages.get(idx) {
                    if is_collapse_capable(block) {
                        let current_collapsed = if expanded_all {
                            false
                        } else {
                            state
                                .collapse_overrides
                                .get(&idx)
                                .copied()
                                .unwrap_or_else(|| {
                                    is_collapsed_view(
                                        block,
                                        idx,
                                        state.messages.len(),
                                        &ui_settings,
                                        expanded_all,
                                    )
                                })
                        };
                        let next_collapsed = !current_collapsed;
                        if let Some(block) = state.messages.get_mut(idx) {
                            set_block_collapsed(block, next_collapsed);
                        }
                        state.collapse_overrides.insert(idx, next_collapsed);
                    }
                }
            }
            state.nav_g_pending_at = None;
            Vec::new()
        }
        KeyCode::Char('g') => {
            let now = std::time::Instant::now();
            let fresh_pending = matches!(
                state.nav_g_pending_at,
                Some(t) if now.duration_since(t) <= std::time::Duration::from_secs(1)
            );
            if fresh_pending {
                state.nav_selected =
                    first_visible_index(&state.messages, &ui_settings, expanded_all);
                maybe_scroll_into_view(state);
                state.nav_g_pending_at = None;
            } else {
                state.nav_g_pending_at = Some(now);
            }
            Vec::new()
        }
        KeyCode::Char('G') => {
            state.nav_selected = last_visible_index(&state.messages, &ui_settings, expanded_all);
            maybe_scroll_into_view(state);
            state.nav_g_pending_at = None;
            Vec::new()
        }
        _ => {
            // Unknown key clears the gg-pending state.
            state.nav_g_pending_at = None;
            Vec::new()
        }
    }
}

/// Move up from `current`, skipping any hidden blocks. Saturates at 0.
/// In Task 4 this is a simple decrement; Task 7 wires `is_visible` to skip.
fn prev_visible_index(
    messages: &[MessageBlock],
    current: usize,
    settings: &UiSettings,
    expanded_all: bool,
) -> usize {
    for i in (0..current).rev() {
        if is_visible(&messages[i], settings, expanded_all) {
            return i;
        }
    }
    current
}

/// Move down from `current`, saturating at `messages.len() - 1`.
fn next_visible_index(
    messages: &[MessageBlock],
    current: usize,
    settings: &UiSettings,
    expanded_all: bool,
) -> usize {
    for (i, message) in messages.iter().enumerate().skip(current + 1) {
        if is_visible(message, settings, expanded_all) {
            return i;
        }
    }
    current
}

fn is_collapse_capable(block: &MessageBlock) -> bool {
    matches!(
        block,
        MessageBlock::ToolCall { .. }
            | MessageBlock::ToolResult { .. }
            | MessageBlock::Thinking { .. }
            | MessageBlock::Assistant { .. }
    )
}

fn set_block_collapsed(block: &mut MessageBlock, next_collapsed: bool) {
    match block {
        MessageBlock::ToolCall { collapsed, .. }
        | MessageBlock::ToolResult { collapsed, .. }
        | MessageBlock::Thinking { collapsed, .. }
        | MessageBlock::Assistant { collapsed, .. } => *collapsed = next_collapsed,
        MessageBlock::User { .. } | MessageBlock::Notice { .. } | MessageBlock::Error(_) => {}
    }
}

fn normalize_nav_selection(state: &mut AppState) {
    if !state.nav_mode {
        return;
    }
    let ui_settings = state
        .settings_snapshot
        .as_ref()
        .map(|s| s.ui.clone())
        .unwrap_or_default();
    let expanded_all = state.expanded_all;

    if let Some(selected) = state.nav_selected {
        if state
            .messages
            .get(selected)
            .is_some_and(|b| is_visible(b, &ui_settings, expanded_all))
        {
            return;
        }

        let previous_start = selected.min(state.messages.len());
        let previous = (0..previous_start)
            .rev()
            .find(|&i| is_visible(&state.messages[i], &ui_settings, expanded_all));
        let next = state
            .messages
            .iter()
            .enumerate()
            .skip(selected.saturating_add(1))
            .find(|(_, block)| is_visible(block, &ui_settings, expanded_all))
            .map(|(i, _)| i);

        state.nav_selected = match (previous, next) {
            (Some(prev), Some(next)) if selected - prev <= next - selected => Some(prev),
            (Some(_), Some(next)) => Some(next),
            (Some(prev), None) => Some(prev),
            (None, Some(next)) => Some(next),
            (None, None) => None,
        };
        return;
    }

    state.nav_selected = None;
}

fn handle_bottom_pane_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crossterm::event::KeyCode;

    // Palette routes to its own handler (avoids a borrow tangle).
    if matches!(state.bottom_pane, Some(BottomPane::Palette(_))) {
        return handle_palette_key(state, key);
    }
    if matches!(state.bottom_pane, Some(BottomPane::FilePicker(_))) {
        return handle_file_picker_key(state, key);
    }
    if matches!(state.bottom_pane, Some(BottomPane::ModelPicker(_))) {
        return handle_model_picker_key(state, key);
    }
    if matches!(state.bottom_pane, Some(BottomPane::ResumePicker(_))) {
        return handle_resume_picker_key(state, key);
    }
    if matches!(state.bottom_pane, Some(BottomPane::ForkPicker(_))) {
        return handle_fork_picker_key(state, key);
    }
    if matches!(state.bottom_pane, Some(BottomPane::BranchTree(_))) {
        return handle_branch_tree_key(state, key);
    }
    if matches!(state.bottom_pane, Some(BottomPane::Settings(_))) {
        return handle_settings_pane_key(state, key);
    }

    let Some(pane) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };

    match pane {
        BottomPane::Approval(approval) => {
            // Resolve the option that the user picked. Returns the chosen
            // index if a decision was made, `None` for nav-only keys.
            //
            // Esc is always Cancel — never silently "continue" — mirroring
            // codex's deliberate guard in `bottom_pane/approval_overlay.rs`
            // around MCP elicitation (`Esc mapped to Cancel, even with
            // custom keybindings, so dismissal never silently becomes
            // "continue without info"`).
            let chosen_idx: Option<usize> = match key.code {
                KeyCode::Up => {
                    if approval.selected > 0 {
                        approval.selected -= 1;
                    }
                    None
                }
                KeyCode::Down => {
                    if approval.selected + 1 < approval.options.len() {
                        approval.selected += 1;
                    }
                    None
                }
                KeyCode::Enter => Some(approval.selected),
                KeyCode::Esc => approval
                    .options
                    .iter()
                    .position(|opt| matches!(opt.choice, PermissionChoice::Deny)),
                KeyCode::Char(c) => {
                    let lower = c.to_ascii_lowercase();
                    approval
                        .options
                        .iter()
                        .position(|opt| opt.shortcut == Some(lower))
                }
                _ => None,
            };

            let Some(idx) = chosen_idx else {
                return Vec::new();
            };
            let Some(option) = approval.options.get(idx).cloned() else {
                return Vec::new();
            };

            let tool = approval.tool.clone();
            let args = approval.args.clone();
            if let Some(tx) = approval.resolver.take() {
                let _ = tx.send(option.decision);
            }
            state.bottom_pane = None;

            vec![Command::ResolvePermission(PermissionResolution {
                tool,
                args,
                choice: option.choice,
            })]
        }
        BottomPane::Palette(_) => Vec::new(), // unreachable — handled above
        BottomPane::FilePicker(_) => Vec::new(), // Task 5/6 wires this
        BottomPane::ModelPicker(_) | BottomPane::ResumePicker(_) => Vec::new(), // Task 5/6 wires this
        BottomPane::ForkPicker(_) => Vec::new(),                                // Task 8
        BottomPane::BranchTree(_) => Vec::new(),                                // Task 5/6
        BottomPane::Settings(_) => Vec::new(),                                  // Task 6
    }
}

fn handle_file_picker_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crossterm::event::KeyCode;

    // Re-filter helper: recompute matches + clamp selection after the query
    // changes. Pulled out so typing and backspace share it.
    fn refilter(state: &mut AppState) {
        if let Some(BottomPane::FilePicker(fp)) = state.bottom_pane.as_mut() {
            fp.matches = state.file_index.filter(&fp.query, 8);
            if fp.selected >= fp.matches.len() {
                fp.selected = fp.matches.len().saturating_sub(1);
            }
        }
    }

    let Some(BottomPane::FilePicker(fp)) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };

    match key.code {
        KeyCode::Esc => {
            state.bottom_pane = None;
            Vec::new()
        }
        KeyCode::Up => {
            fp.selected = fp.selected.saturating_sub(1);
            Vec::new()
        }
        KeyCode::Down => {
            let max = fp.matches.len().saturating_sub(1);
            fp.selected = (fp.selected + 1).min(max);
            Vec::new()
        }
        KeyCode::Char(c) => {
            fp.query.push(c);
            refilter(state);
            Vec::new()
        }
        KeyCode::Backspace => {
            if fp.query.is_empty() {
                state.bottom_pane = None;
            } else {
                fp.query.pop();
                refilter(state);
            }
            Vec::new()
        }
        KeyCode::Enter => {
            let chosen = fp.matches.get(fp.selected).cloned();
            let anchor = fp.anchor_byte;
            state.bottom_pane = None;
            if let Some(path) = chosen {
                // Insert `<path> ` at the anchor (where `@` was typed).
                let anchor = anchor.min(state.editor.text.len());
                state.editor.text.insert_str(anchor, &format!("{path} "));
                state.editor.cursor_byte = anchor + path.len() + 1;
            }
            Vec::new()
        }
        _ => Vec::new(),
    }
}

fn handle_model_picker_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crossterm::event::KeyCode;
    let Some(BottomPane::ModelPicker(mp)) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };
    match key.code {
        KeyCode::Esc => {
            state.bottom_pane = None;
            Vec::new()
        }
        KeyCode::Up => {
            mp.selected = mp.selected.saturating_sub(1);
            Vec::new()
        }
        KeyCode::Down => {
            let max = crate::widgets::model_picker::rows(&mp.query)
                .len()
                .min(crate::widgets::model_picker::MAX_ROWS)
                .saturating_sub(1);
            mp.selected = (mp.selected + 1).min(max);
            Vec::new()
        }
        KeyCode::Char(c) => {
            mp.query.push(c);
            mp.selected = 0;
            Vec::new()
        }
        KeyCode::Backspace => {
            mp.query.pop();
            mp.selected = 0;
            Vec::new()
        }
        KeyCode::Enter => {
            let rows = crate::widgets::model_picker::rows(&mp.query);
            let chosen = rows
                .iter()
                .take(crate::widgets::model_picker::MAX_ROWS)
                .nth(mp.selected)
                .cloned();
            let query = mp.query.clone();
            state.bottom_pane = None;
            match chosen {
                None => Vec::new(),
                Some(row) => {
                    // A free-type row is rendered as `use "<query>"`.
                    let model = if row.starts_with("use \"") {
                        query
                    } else {
                        row
                    };
                    if model.is_empty() {
                        Vec::new()
                    } else {
                        vec![Command::SwitchModel(capo_agent::ModelId::from(model))]
                    }
                }
            }
        }
        _ => Vec::new(),
    }
}

fn handle_resume_picker_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crossterm::event::KeyCode;
    let session_count = state
        .sessions
        .len()
        .min(crate::widgets::resume_picker::MAX_ROWS);
    let Some(BottomPane::ResumePicker(rp)) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };
    match key.code {
        KeyCode::Esc => {
            state.bottom_pane = None;
            Vec::new()
        }
        KeyCode::Up => {
            rp.selected = rp.selected.saturating_sub(1);
            Vec::new()
        }
        KeyCode::Down => {
            rp.selected = (rp.selected + 1).min(session_count.saturating_sub(1));
            Vec::new()
        }
        KeyCode::Enter => {
            let idx = rp.selected;
            state.bottom_pane = None;
            match state
                .sessions
                .iter()
                .take(crate::widgets::resume_picker::MAX_ROWS)
                .nth(idx)
            {
                Some(s) => vec![Command::LoadSession(s.id.clone())],
                None => Vec::new(),
            }
        }
        _ => Vec::new(),
    }
}

fn handle_branch_tree_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crossterm::event::KeyCode;
    let node_count = state
        .branch_tree
        .as_ref()
        .map(|t| t.nodes.len().min(crate::widgets::branch_tree::MAX_ROWS))
        .unwrap_or(0);
    let Some(BottomPane::BranchTree(bt)) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };
    match key.code {
        KeyCode::Esc => {
            state.bottom_pane = None;
            Vec::new()
        }
        KeyCode::Up => {
            bt.selected = bt.selected.saturating_sub(1);
            Vec::new()
        }
        KeyCode::Down => {
            bt.selected = (bt.selected + 1).min(node_count.saturating_sub(1));
            Vec::new()
        }
        KeyCode::Enter => {
            let idx = bt.selected;
            state.bottom_pane = None;
            if let Some(tree) = state.branch_tree.as_ref() {
                if let Some(node) = tree.nodes.get(idx) {
                    state.pending_fork = Some(node.id.clone());
                }
            }
            Vec::new()
        }
        _ => Vec::new(),
    }
}

fn handle_fork_picker_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crossterm::event::KeyCode;
    let count = state.fork_candidates.len();
    let Some(BottomPane::ForkPicker(fp)) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };
    match key.code {
        KeyCode::Esc => {
            state.bottom_pane = None;
            Vec::new()
        }
        KeyCode::Up => {
            fp.selected = fp.selected.saturating_sub(1);
            Vec::new()
        }
        KeyCode::Down => {
            fp.selected = (fp.selected + 1).min(count.saturating_sub(1));
            Vec::new()
        }
        KeyCode::Enter => {
            let idx = fp.selected;
            state.bottom_pane = None;
            if let Some(c) = state.fork_candidates.get(idx) {
                state.pending_fork = Some(c.id.clone());
            }
            Vec::new()
        }
        _ => Vec::new(),
    }
}

fn handle_palette_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crossterm::event::KeyCode;

    let extension_commands = state.extension_commands.clone();
    let Some(BottomPane::Palette(palette)) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };

    match key.code {
        KeyCode::Esc => {
            state.bottom_pane = None;
            Vec::new()
        }
        KeyCode::Up => {
            palette.selected = palette.selected.saturating_sub(1);
            Vec::new()
        }
        KeyCode::Down => {
            let max = palette
                .filtered(&extension_commands)
                .len()
                .saturating_sub(1);
            palette.selected = (palette.selected + 1).min(max);
            Vec::new()
        }
        KeyCode::Backspace => {
            if palette.filter.is_empty() {
                state.bottom_pane = None;
            } else {
                palette.filter.pop();
                palette.clamp_selection(&extension_commands);
            }
            Vec::new()
        }
        KeyCode::Char(c) => {
            palette.filter.push(c);
            palette.clamp_selection(&extension_commands);
            Vec::new()
        }
        KeyCode::Enter => {
            let filtered = palette.filtered(&extension_commands);
            let Some(cmd) = filtered.get(palette.selected).cloned() else {
                return Vec::new();
            };
            if !cmd.enabled {
                return Vec::new();
            }
            state.bottom_pane = None;
            dispatch_slash_command(state, &cmd.name)
        }
        _ => Vec::new(),
    }
}

/// Run a Phase-B slash command. `/help` and `/quit` are TUI-local;
/// `/compact` emits `Command::Compact`.
fn dispatch_slash_command(state: &mut AppState, name: &str) -> Vec<Command> {
    match name {
        "quit" => {
            state.should_quit = true;
            vec![Command::Quit]
        }
        "compact" => vec![Command::Compact],
        "extensions" => vec![Command::ListExtensions],
        "fork" => {
            state.bottom_pane = Some(BottomPane::ForkPicker(
                crate::state::ForkPickerState::default(),
            ));
            Vec::new()
        }
        "tree" => {
            state.bottom_pane = Some(BottomPane::BranchTree(
                crate::state::BranchTreeState::default(),
            ));
            Vec::new()
        }
        "new" => vec![Command::NewSession],
        "clone" => vec![Command::CloneSession],
        "model" => {
            state.bottom_pane = Some(BottomPane::ModelPicker(
                crate::state::ModelPickerState::default(),
            ));
            Vec::new()
        }
        "resume" => {
            state.bottom_pane = Some(BottomPane::ResumePicker(
                crate::state::ResumePickerState::default(),
            ));
            Vec::new()
        }
        "settings" => {
            if let Some(snapshot) = state.settings_snapshot.clone() {
                state.bottom_pane = Some(BottomPane::Settings(
                    crate::settings_editor::SettingsState::new(snapshot),
                ));
                Vec::new()
            } else {
                vec![Command::ReloadSettings]
            }
        }
        "help" => {
            let mut help = String::from("Slash commands:\n");
            for c in crate::slash::builtin_commands() {
                let tag = if c.enabled { "" } else { "  (coming soon)" };
                help.push_str(&format!("  /{}  — {}{}\n", c.name, c.description, tag));
            }
            help.push_str(
                "\nKeys: Enter send · Shift+Enter newline · Esc cancel · Ctrl+C twice quit · PgUp/PgDn scroll",
            );
            state.messages.push(MessageBlock::Assistant {
                text: help,
                collapsed: false,
            });
            Vec::new()
        }
        other => {
            // Check if this is an extension-registered command. Palette
            // invocations do not surface slash args in v0.7; args are
            // always empty here.
            if state.extension_commands.iter().any(|c| c == other) {
                vec![Command::InvokeExtensionCommand {
                    name: other.to_string(),
                    args: String::new(),
                }]
            } else {
                Vec::new()
            }
        }
    }
}

/// Build the three default approval options for a permission prompt.
/// Centralised so the labels stay consistent everywhere the pane is
/// opened (currently just `handle_ui::PermissionRequested`).
fn default_approval_options() -> Vec<ApprovalOption> {
    vec![
        ApprovalOption {
            label: "Yes, just this once".into(),
            choice: PermissionChoice::AllowOnce,
            decision: Decision::Allowed,
            shortcut: Some('a'),
        },
        ApprovalOption {
            label: "Yes, and don't ask again this session".into(),
            choice: PermissionChoice::AllowSession,
            decision: Decision::Allowed,
            shortcut: Some('s'),
        },
        ApprovalOption {
            label: "No, deny this call".into(),
            choice: PermissionChoice::Deny,
            decision: Decision::Denied("user denied".into()),
            shortcut: Some('d'),
        },
    ]
}

fn append_progress_chunk(
    stdout: &mut String,
    stderr: &mut String,
    chunk: capo_agent::ProgressChunk,
) {
    match chunk {
        capo_agent::ProgressChunk::Stdout(bytes) => {
            stdout.push_str(&String::from_utf8_lossy(&bytes));
        }
        capo_agent::ProgressChunk::Stderr(bytes) => {
            stderr.push_str(&String::from_utf8_lossy(&bytes));
        }
        capo_agent::ProgressChunk::Status(_) => {}
    }
}

fn handle_settings_pane_key(state: &mut AppState, key: crossterm::event::KeyEvent) -> Vec<Command> {
    use crate::settings_editor::{EditCursor, FieldKind, SettingsRow, SettingsValue};
    use crossterm::event::KeyCode;

    let Some(BottomPane::Settings(settings)) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };

    match (key.code, &mut settings.edit) {
        // Edit-mode keys.
        (KeyCode::Esc, Some(_)) => {
            settings.edit = None;
            Vec::new()
        }
        (KeyCode::Enter, Some(EditCursor::Text { buffer, .. })) => {
            let result = if let SettingsRow::Field { value, kind, .. } =
                &mut settings.rows[settings.selected]
            {
                commit_text_into_value(value, kind, buffer)
            } else {
                Ok(())
            };
            match result {
                Ok(()) => {
                    if let SettingsRow::Field { value, kind, .. } =
                        &settings.rows[settings.selected]
                    {
                        match crate::settings_editor::validate(value, kind) {
                            Ok(()) => {
                                settings.errors.remove(&settings.selected);
                                settings.edit = None;
                            }
                            Err(msg) => {
                                settings.errors.insert(settings.selected, msg);
                            }
                        }
                    } else {
                        settings.edit = None;
                    }
                }
                Err(msg) => {
                    settings.errors.insert(settings.selected, msg);
                }
            }
            Vec::new()
        }
        (KeyCode::Enter, Some(EditCursor::EnumPicker { current_idx })) => {
            let idx_val = *current_idx;
            if let SettingsRow::Field {
                value,
                kind: FieldKind::Enum { options },
                ..
            } = &mut settings.rows[settings.selected]
            {
                if let Some(option) = options.get(idx_val) {
                    *value = SettingsValue::String((*option).to_string());
                    settings.errors.remove(&settings.selected);
                }
            }
            settings.edit = None;
            Vec::new()
        }
        (KeyCode::Left | KeyCode::Right, Some(EditCursor::EnumPicker { current_idx })) => {
            if let SettingsRow::Field {
                kind: FieldKind::Enum { options },
                ..
            } = &settings.rows[settings.selected]
            {
                let len = options.len();
                if len > 0 {
                    if key.code == KeyCode::Left {
                        *current_idx = (*current_idx + len - 1) % len;
                    } else {
                        *current_idx = (*current_idx + 1) % len;
                    }
                }
            }
            Vec::new()
        }
        (
            KeyCode::Char(c),
            Some(EditCursor::Text {
                buffer,
                cursor_byte,
            }),
        ) => {
            buffer.insert(*cursor_byte, c);
            *cursor_byte += c.len_utf8();
            Vec::new()
        }
        (
            KeyCode::Backspace,
            Some(EditCursor::Text {
                buffer,
                cursor_byte,
            }),
        ) => {
            if *cursor_byte > 0 {
                let new_byte = buffer[..*cursor_byte]
                    .char_indices()
                    .last()
                    .map(|(i, _)| i)
                    .unwrap_or(0);
                buffer.replace_range(new_byte..*cursor_byte, "");
                *cursor_byte = new_byte;
            }
            Vec::new()
        }
        (KeyCode::F(2), _) => save_settings_pane(state),
        // List-mode keys.
        (KeyCode::Esc, None) => {
            state.bottom_pane = None;
            Vec::new()
        }
        (KeyCode::Up, None) => {
            settings.selected = find_prev_field_row(&settings.rows, settings.selected);
            Vec::new()
        }
        (KeyCode::Down, None) => {
            settings.selected = find_next_field_row(&settings.rows, settings.selected);
            Vec::new()
        }
        (KeyCode::Enter, None) => {
            if let SettingsRow::Field { kind, value, .. } = &mut settings.rows[settings.selected] {
                if matches!(kind, FieldKind::Bool) {
                    if let SettingsValue::Bool(b) = value {
                        *b = !*b;
                        settings.errors.remove(&settings.selected);
                    }
                } else {
                    settings.edit = Some(init_edit_cursor(value, kind));
                }
            }
            Vec::new()
        }
        (KeyCode::Char(' '), None) => {
            if let SettingsRow::Field {
                value: SettingsValue::Bool(b),
                kind: FieldKind::Bool,
                ..
            } = &mut settings.rows[settings.selected]
            {
                *b = !*b;
                settings.errors.remove(&settings.selected);
            }
            Vec::new()
        }
        _ => Vec::new(),
    }
}

fn save_settings_pane(state: &mut AppState) -> Vec<Command> {
    let Some(BottomPane::Settings(settings)) = state.bottom_pane.as_mut() else {
        return Vec::new();
    };

    match settings.edit.clone() {
        Some(crate::settings_editor::EditCursor::Text { buffer, .. }) => {
            if let crate::settings_editor::SettingsRow::Field { value, kind, .. } =
                &mut settings.rows[settings.selected]
            {
                if let Err(msg) = commit_text_into_value(value, kind, &buffer) {
                    settings.errors.insert(settings.selected, msg);
                    return Vec::new();
                }
            }
        }
        Some(crate::settings_editor::EditCursor::EnumPicker { current_idx }) => {
            if let crate::settings_editor::SettingsRow::Field {
                value,
                kind: crate::settings_editor::FieldKind::Enum { options },
                ..
            } = &mut settings.rows[settings.selected]
            {
                if let Some(option) = options.get(current_idx) {
                    *value = crate::settings_editor::SettingsValue::String((*option).to_string());
                }
            }
        }
        Some(crate::settings_editor::EditCursor::Toggle) | None => {}
    }
    settings.edit = None;

    match crate::settings_editor::try_save(settings) {
        Some(cmd) => {
            state.bottom_pane = None;
            vec![cmd]
        }
        None => Vec::new(),
    }
}

fn find_next_field_row(rows: &[crate::settings_editor::SettingsRow], from: usize) -> usize {
    for (idx, row) in rows.iter().enumerate().skip(from.saturating_add(1)) {
        if matches!(row, crate::settings_editor::SettingsRow::Field { .. }) {
            return idx;
        }
    }
    from
}

fn find_prev_field_row(rows: &[crate::settings_editor::SettingsRow], from: usize) -> usize {
    for idx in (0..from).rev() {
        if matches!(rows[idx], crate::settings_editor::SettingsRow::Field { .. }) {
            return idx;
        }
    }
    from
}

fn init_edit_cursor(
    value: &crate::settings_editor::SettingsValue,
    kind: &crate::settings_editor::FieldKind,
) -> crate::settings_editor::EditCursor {
    use crate::settings_editor::{EditCursor, FieldKind, SettingsValue};
    match (value, kind) {
        (SettingsValue::String(s), FieldKind::Enum { options }) => {
            let idx = options.iter().position(|o| *o == s).unwrap_or(0);
            EditCursor::EnumPicker { current_idx: idx }
        }
        _ => {
            let buffer = value_to_text(value);
            let cursor_byte = buffer.len();
            EditCursor::Text {
                buffer,
                cursor_byte,
            }
        }
    }
}

fn value_to_text(value: &crate::settings_editor::SettingsValue) -> String {
    use crate::settings_editor::SettingsValue;
    match value {
        SettingsValue::String(s) => s.clone(),
        SettingsValue::U32(n) => n.to_string(),
        SettingsValue::Usize(n) => n.to_string(),
        SettingsValue::F32(f) => f.to_string(),
        SettingsValue::Bool(b) => b.to_string(),
    }
}

fn sync_settings_editor_model_name(
    settings: &mut crate::settings_editor::SettingsState,
    model_name: &str,
) {
    use crate::settings_editor::{EditCursor, SettingsRow, SettingsValue};

    settings.original.model.name = model_name.to_string();

    let mut model_name_idx = None;
    for (idx, row) in settings.rows.iter_mut().enumerate() {
        if let SettingsRow::Field {
            section,
            key,
            value: SettingsValue::String(value),
            ..
        } = row
        {
            if *section == "model" && *key == "name" {
                *value = model_name.to_string();
                model_name_idx = Some(idx);
            }
        }
    }

    if let Some(idx) = model_name_idx {
        settings.errors.remove(&idx);
        if settings.selected == idx {
            if let Some(EditCursor::Text {
                buffer,
                cursor_byte,
            }) = settings.edit.as_mut()
            {
                *buffer = model_name.to_string();
                *cursor_byte = buffer.len();
            }
        }
    }
}

fn commit_text_into_value(
    value: &mut crate::settings_editor::SettingsValue,
    _kind: &crate::settings_editor::FieldKind,
    buffer: &str,
) -> Result<(), String> {
    use crate::settings_editor::SettingsValue;
    match value {
        SettingsValue::String(_) => {
            *value = SettingsValue::String(buffer.to_string());
            Ok(())
        }
        SettingsValue::U32(_) => match buffer.parse::<u32>() {
            Ok(n) => {
                *value = SettingsValue::U32(n);
                Ok(())
            }
            Err(_) => Err("must be a u32".to_string()),
        },
        SettingsValue::Usize(_) => match buffer.parse::<usize>() {
            Ok(n) => {
                *value = SettingsValue::Usize(n);
                Ok(())
            }
            Err(_) => Err("must be a usize".to_string()),
        },
        SettingsValue::F32(_) => match buffer.parse::<f32>() {
            Ok(f) => {
                *value = SettingsValue::F32(f);
                Ok(())
            }
            Err(_) => Err("must be a number".to_string()),
        },
        SettingsValue::Bool(_) => {
            *value = SettingsValue::Bool(buffer.eq_ignore_ascii_case("true"));
            Ok(())
        }
    }
}

fn handle_ui(state: &mut AppState, event: UiEvent) -> Vec<Command> {
    match event {
        UiEvent::AgentTurnStarted => {
            state.pending_turn_submit = None;
            state.in_flight = Some(InFlight::Thinking);
        }
        UiEvent::AgentThinking => {
            state.in_flight = Some(InFlight::Thinking);
        }
        UiEvent::AgentTextDelta(chunk) => match &mut state.in_flight {
            Some(InFlight::StreamingText { partial }) => partial.push_str(&chunk),
            _ => state.in_flight = Some(InFlight::StreamingText { partial: chunk }),
        },
        UiEvent::ThinkingComplete { text } => {
            state.messages.push(MessageBlock::Thinking {
                text,
                collapsed: false,
            });
        }
        UiEvent::AgentMessageComplete(text) => {
            if !text.is_empty() {
                state.messages.push(MessageBlock::Assistant {
                    text,
                    collapsed: false,
                });
            }
            state.in_flight = None;
        }
        UiEvent::ToolCallStarted { id, name, args } => {
            state.in_flight = Some(InFlight::ToolCall {
                id: id.clone(),
                name: name.clone(),
                args: args.clone(),
            });
            state.messages.push(MessageBlock::ToolCall {
                id,
                name,
                args,
                collapsed: false,
            });
        }
        UiEvent::ToolCallProgress { id, chunk } => match &mut state.in_flight {
            Some(InFlight::ToolRunning {
                id: cur,
                stdout,
                stderr,
                ..
            }) if cur == &id => append_progress_chunk(stdout, stderr, chunk),
            Some(InFlight::ToolCall { id: cur, name, .. }) if cur == &id => {
                let name = name.clone();
                state.in_flight = Some(InFlight::ToolRunning {
                    id,
                    name,
                    stdout: String::new(),
                    stderr: String::new(),
                });
                if let Some(InFlight::ToolRunning { stdout, stderr, .. }) = &mut state.in_flight {
                    append_progress_chunk(stdout, stderr, chunk);
                }
            }
            _ => {}
        },
        UiEvent::ToolCallCompleted { id, result } => {
            state.messages.push(MessageBlock::ToolResult {
                id,
                result,
                collapsed: false,
            });
            state.in_flight = None;
        }
        UiEvent::AgentTurnComplete => {
            state.in_flight = None;
        }
        UiEvent::TurnStats {
            input_tokens: _,
            output_tokens: _,
            cumulative_input,
            cumulative_output,
            model,
        } => {
            // Update footer with cumulative counts. show_cost gating
            // happens in the renderer (see widgets/footer.rs).
            state.footer.input_tokens = cumulative_input;
            state.footer.output_tokens = cumulative_output;
            state.footer.model = model;
            state.footer.turn_count = state.footer.turn_count.saturating_add(1);
        }
        UiEvent::PermissionRequested {
            tool,
            args,
            resolver,
        } => {
            // Phase E: resolver is populated by PermissionExtension; the
            // approval pane's option-select sends a Decision through the
            // oneshot channel back to the extension, which translates
            // Allow/Deny into ToolDecision::Proceed/ShortCircuit.
            //
            // Codex-style bottom-pane overlay: the editor is replaced by
            // a navigable list. Options are the three fixed M2 choices —
            // when the PermissionExtension protocol grows dynamic
            // `available_decisions` (codex pattern), this is where the
            // list would be built from the request.
            let args_preview =
                serde_json::to_string_pretty(&args).unwrap_or_else(|_| args.to_string());
            let question = format!("Allow capo to run this `{tool}` call?");
            state.bottom_pane = Some(BottomPane::Approval(ApprovalState {
                question,
                args_preview,
                options: default_approval_options(),
                selected: 0,
                tool,
                args,
                resolver: Some(resolver),
            }));
        }
        UiEvent::PermissionModeChanged { mode } => {
            state.permission_mode = mode;
            // Footer re-renders on next frame via footer.rs reading
            // state.permission_mode (Task 7 wires this).
        }
        UiEvent::InlineBashOutput { command, output } => {
            state.messages.push(MessageBlock::Notice {
                title: format!("$ {command}"),
                body: output,
            });
        }
        UiEvent::Notice { title, body } => {
            state.messages.push(MessageBlock::Notice { title, body });
        }
        UiEvent::SessionReplaced {
            session_id,
            history: messages,
        } => {
            let same_session = state.current_session_id.as_ref() == Some(&session_id);
            if !same_session {
                state.collapse_overrides.clear();
                if !state.nav_mode {
                    state.nav_selected = None;
                }
                state.current_session_id = Some(session_id);
            }

            state.messages.clear();
            state.in_flight = None;
            state.scroll_lines_from_bottom = 0;
            state.footer.input_tokens = 0;
            state.footer.output_tokens = 0;
            state.footer.turn_count = 0;
            for msg in &messages {
                // v0.9 Phase A: surface thinking blocks from historical assistant
                // messages. Pushed BEFORE the 1:1 translation so transcript order
                // matches live: thinking → text.
                if let capo_agent::Message::Assistant { content, .. } = msg {
                    for part in content {
                        if let capo_agent::AssistantContent::Reasoning { text, .. } = part {
                            state.messages.push(MessageBlock::Thinking {
                                text: text.clone(),
                                collapsed: false,
                            });
                        }
                    }
                }
                let text = msg.text();
                if text.is_empty() {
                    continue;
                }
                match msg.role() {
                    capo_agent::Role::User => {
                        state.messages.push(MessageBlock::User {
                            text,
                            attachment_filenames: Vec::new(),
                        });
                    }
                    capo_agent::Role::Assistant => {
                        state.messages.push(MessageBlock::Assistant {
                            text,
                            collapsed: false,
                        });
                    }
                    _ => {}
                }
            }
            normalize_nav_selection(state);
        }
        UiEvent::ModelSwitched(model) => {
            let model_name = model.to_string();
            state.footer.model = model_name.clone();
            if let Some(settings) = state.settings_snapshot.as_mut() {
                settings.model.name = model_name.clone();
            }
            if let Some(BottomPane::Settings(settings)) = state.bottom_pane.as_mut() {
                sync_settings_editor_model_name(settings, &model_name);
            }
            state.messages.push(MessageBlock::Notice {
                title: "model".into(),
                body: format!("switched to model {model}"),
            });
        }
        UiEvent::ForkCandidates(pairs) => {
            state.fork_candidates = pairs
                .into_iter()
                .map(|(id, preview)| crate::state::ForkCandidate { id, preview })
                .collect();
        }
        UiEvent::BranchTree(tree) => {
            state.branch_tree = Some(tree);
        }
        UiEvent::MessagesSnapshot(_) | UiEvent::StateSnapshot { .. } => {}
        UiEvent::SettingsSnapshot { settings } => {
            state.footer.show_cost = settings.ui.footer_show_cost;
            state.footer.model = settings.model.name.clone();
            state.footer.input_tokens = 0;
            state.footer.output_tokens = 0;
            state.footer.turn_count = 0;
            state.settings_snapshot = Some(settings);
            normalize_nav_selection(state);
        }
        UiEvent::ExtensionList { extensions } => {
            // Update the palette's awareness of extension-registered commands.
            state.extension_commands = extensions
                .iter()
                .filter(|ext| ext.healthy)
                .flat_map(|ext| ext.commands.iter().cloned())
                .collect();

            let mut body = String::new();
            if extensions.is_empty() {
                body.push_str("(no extensions registered)");
            } else {
                use std::fmt::Write;
                for ext in &extensions {
                    let marker = if ext.healthy { "✓" } else { "✗" };
                    let name = ext.name.as_str();
                    let _ = write!(body, "  {marker} {name}");
                    if !ext.hooks.is_empty() {
                        let hooks = ext.hooks.join(", ");
                        let _ = write!(body, "    hooks: {hooks}");
                    }
                    if !ext.commands.is_empty() {
                        let cmds: Vec<String> =
                            ext.commands.iter().map(|c| format!("/{c}")).collect();
                        let commands = cmds.join(", ");
                        let _ = write!(body, "    commands: {commands}");
                    }
                    if let Some(diag) = &ext.diagnostic {
                        let _ = write!(body, "    ({diag})");
                    }
                    body.push('\n');
                }
                body = body.trim_end().to_string();
            }
            let count = extensions.len();
            state.messages.push(MessageBlock::Notice {
                title: format!("Extensions ({count})"),
                body,
            });
        }
        UiEvent::ExtensionCancelled {
            extension_name,
            reason,
        } => {
            if let Some(pending) = state.pending_turn_submit.take() {
                if matches!(
                    state.messages.get(pending.message_index),
                    Some(MessageBlock::User { .. })
                ) {
                    state.messages.remove(pending.message_index);
                }
                state.editor.text = pending.composer_text;
                state.editor.cursor_byte = state.editor.text.len();
            }
            let body = match reason {
                Some(r) => format!("extension `{extension_name}` cancelled: {r}"),
                None => format!("extension `{extension_name}` cancelled the action"),
            };
            state.messages.push(MessageBlock::Notice {
                title: format!("Extension {extension_name}"),
                body,
            });
            state.in_flight = None;
        }
        UiEvent::Error(msg) => {
            state.messages.push(MessageBlock::Error(msg));
            state.in_flight = None;
        }
        UiEvent::AttachmentError { kind: _, message } => {
            if let Some(pending) = state.pending_turn_submit.take() {
                if matches!(
                    state.messages.get(pending.message_index),
                    Some(MessageBlock::User { .. })
                ) {
                    state.messages.remove(pending.message_index);
                }
                state.editor.text = pending.composer_text;
                state.editor.cursor_byte = state.editor.text.len();
            }
            state.messages.push(MessageBlock::Error(message));
            state.in_flight = None;
        }
    }
    Vec::new()
}

#[cfg(test)]
mod tests {
    use super::*;
    use capo_agent::UiEvent;

    use crate::state::{InFlight, MessageBlock};

    #[test]
    fn attachment_error_pushes_error_block_and_clears_in_flight() {
        let mut s = AppState {
            in_flight: Some(InFlight::Thinking),
            ..AppState::default()
        };
        let cmds = update(
            &mut s,
            UiEvent::AttachmentError {
                kind: capo_agent::AttachmentErrorKind::NotFound,
                message: "image not found: /tmp/foo.png".into(),
            }
            .into(),
        );
        assert!(cmds.is_empty());
        assert!(matches!(
            s.messages.last(),
            Some(MessageBlock::Error(m)) if m.contains("not found")
        ));
        assert!(s.in_flight.is_none());
    }

    #[test]
    fn turn_started_sets_thinking() {
        let mut s = AppState::default();
        let cmds = update(&mut s, UiEvent::AgentTurnStarted.into());
        assert!(cmds.is_empty());
        assert!(matches!(s.in_flight, Some(InFlight::Thinking)));
    }

    #[test]
    fn extension_cancelled_rolls_back_optimistic_push_and_restores_buffer() {
        let mut s = AppState::default();
        // Simulate the Enter handler optimistically pushing a user
        // message + setting pending_turn_submit.
        s.messages.push(MessageBlock::User {
            text: "hi".into(),
            attachment_filenames: Vec::new(),
        });
        s.pending_turn_submit = Some(crate::state::PendingTurnSubmit {
            composer_text: "hi".into(),
            message_index: 0,
        });
        s.in_flight = Some(InFlight::Thinking);

        let cmds = update(
            &mut s,
            Event::Ui(UiEvent::ExtensionCancelled {
                extension_name: "dirty-repo-guard".into(),
                reason: Some("uncommitted changes".into()),
            }),
        );
        assert!(cmds.is_empty());

        // Optimistic user message removed.
        assert!(!s
            .messages
            .iter()
            .any(|m| matches!(m, MessageBlock::User { text, .. } if text == "hi")));
        // Notice block surfaced.
        assert!(s.messages.iter().any(|m| {
            matches!(m, MessageBlock::Notice { title, body } if title.contains("dirty-repo-guard") && body.contains("uncommitted"))
        }));
        // Composer buffer restored.
        assert_eq!(s.editor.text, "hi");
        // pending_turn_submit cleared.
        assert!(s.pending_turn_submit.is_none());
        // in_flight cleared.
        assert!(s.in_flight.is_none());
    }

    #[test]
    fn text_deltas_accumulate() {
        let mut s = AppState::default();
        update(&mut s, UiEvent::AgentTextDelta("he".into()).into());
        update(&mut s, UiEvent::AgentTextDelta("llo".into()).into());
        match &s.in_flight {
            Some(InFlight::StreamingText { partial }) => assert_eq!(partial, "hello"),
            other => panic!("{other:?}"),
        }
    }

    #[test]
    fn message_complete_finalizes_block_and_clears_in_flight() {
        let mut s = AppState::default();
        update(&mut s, UiEvent::AgentTextDelta("partial".into()).into());
        update(&mut s, UiEvent::AgentMessageComplete("final".into()).into());
        assert!(s.in_flight.is_none());
        assert!(
            matches!(s.messages.last(), Some(MessageBlock::Assistant { text, .. }) if text == "final")
        );
    }

    #[test]
    fn thinking_complete_event_pushes_thinking_block() {
        let mut s = AppState::default();
        let cmds = update(
            &mut s,
            Event::Ui(UiEvent::ThinkingComplete {
                text: "let me consider...".into(),
            }),
        );
        assert!(cmds.is_empty());
        assert_eq!(s.messages.len(), 1);
        match &s.messages[0] {
            MessageBlock::Thinking { text, collapsed } => {
                assert_eq!(text, "let me consider...");
                assert!(!*collapsed, "default to non-collapsed (full view)");
            }
            other => panic!("expected Thinking; got {other:?}"),
        }
    }

    #[test]
    fn inline_bash_output_event_pushes_notice_block() {
        let mut s = AppState::default();
        update(
            &mut s,
            capo_agent::UiEvent::InlineBashOutput {
                command: "echo hi".into(),
                output: "hi\n".into(),
            }
            .into(),
        );
        assert!(matches!(
            s.messages.last(),
            Some(MessageBlock::Notice { title, body })
                if title.contains("echo hi") && body == "hi\n"
        ));
    }

    #[test]
    fn session_replaced_event_rebuilds_transcript() {
        let mut s = AppState::default();
        s.messages.push(MessageBlock::User {
            text: "stale".into(),
            attachment_filenames: Vec::new(),
        });
        s.in_flight = Some(InFlight::Thinking);
        let msgs = vec![
            capo_agent::Message::user("hi"),
            capo_agent::Message::assistant("hello"),
        ];
        update(
            &mut s,
            capo_agent::UiEvent::SessionReplaced {
                session_id: "test".into(),
                history: msgs,
            }
            .into(),
        );
        // Old transcript gone; new one rebuilt; in-flight cleared.
        assert!(s.in_flight.is_none());
        assert!(!s.messages.iter().any(
            |m| matches!(m, MessageBlock::User { text: t, attachment_filenames: _ } if t == "stale")
        ));
        assert!(s.messages.iter().any(
            |m| matches!(m, MessageBlock::User { text: t, attachment_filenames: _ } if t == "hi")
        ));
        assert!(s
            .messages
            .iter()
            .any(|m| matches!(m, MessageBlock::Assistant { text, .. } if text == "hello")));
    }

    #[test]
    fn session_replaced_with_reasoning_surfaces_thinking_blocks() {
        use capo_agent::{new_message_id, AssistantContent, Message, MessageMeta};

        let mut s = AppState::default();
        let history = vec![
            Message::User {
                id: new_message_id(),
                meta: MessageMeta::default(),
                content: vec![capo_agent::ContentPart::text("hello")],
            },
            Message::Assistant {
                id: new_message_id(),
                meta: MessageMeta::default(),
                content: vec![
                    AssistantContent::Reasoning {
                        text: "deliberation".into(),
                        signature: None,
                    },
                    AssistantContent::Text {
                        text: "world".into(),
                    },
                ],
            },
        ];
        let cmds = update(
            &mut s,
            Event::Ui(UiEvent::SessionReplaced {
                session_id: "test".into(),
                history,
            }),
        );
        assert!(cmds.is_empty());

        let thinking_count = s
            .messages
            .iter()
            .filter(|b| matches!(b, MessageBlock::Thinking { .. }))
            .count();
        assert_eq!(
            thinking_count,
            1,
            "expected 1 Thinking block in rebuilt transcript; got {} blocks: {:?}",
            s.messages.len(),
            s.messages
        );

        let first_thinking = s
            .messages
            .iter()
            .position(|b| matches!(b, MessageBlock::Thinking { .. }))
            .unwrap_or_else(|| panic!("thinking present"));
        let first_assistant_text = s
            .messages
            .iter()
            .position(|b| matches!(b, MessageBlock::Assistant { .. }))
            .unwrap_or_else(|| panic!("assistant text present"));
        assert!(
            first_thinking < first_assistant_text,
            "Thinking ({first_thinking}) should come before Assistant ({first_assistant_text})"
        );
    }

    #[test]
    fn session_replaced_same_id_preserves_collapse_overrides() {
        let mut s = AppState {
            current_session_id: Some("sess-A".into()),
            ..AppState::default()
        };
        s.collapse_overrides.insert(2, true);
        s.collapse_overrides.insert(5, false);

        let _ = update(
            &mut s,
            Event::Ui(capo_agent::UiEvent::SessionReplaced {
                session_id: "sess-A".into(),
                history: Vec::new(),
            }),
        );

        assert_eq!(s.collapse_overrides.len(), 2);
        assert_eq!(s.collapse_overrides.get(&2), Some(&true));
        assert_eq!(s.collapse_overrides.get(&5), Some(&false));
    }

    #[test]
    fn session_replaced_different_id_clears_collapse_overrides() {
        let mut s = AppState {
            current_session_id: Some("sess-A".into()),
            ..AppState::default()
        };
        s.collapse_overrides.insert(2, true);

        let _ = update(
            &mut s,
            Event::Ui(capo_agent::UiEvent::SessionReplaced {
                session_id: "sess-B".into(),
                history: Vec::new(),
            }),
        );

        assert!(s.collapse_overrides.is_empty());
        assert_eq!(s.current_session_id.as_deref(), Some("sess-B"));
    }

    #[test]
    fn session_replaced_first_time_sets_current_session_id() {
        let mut s = AppState::default();
        let _ = update(
            &mut s,
            Event::Ui(capo_agent::UiEvent::SessionReplaced {
                session_id: "sess-X".into(),
                history: Vec::new(),
            }),
        );
        assert_eq!(s.current_session_id.as_deref(), Some("sess-X"));
        assert!(s.collapse_overrides.is_empty());
    }

    #[test]
    fn session_replaced_empty_clears_transcript() {
        let mut s = AppState::default();
        s.messages.push(MessageBlock::User {
            text: "stale".into(),
            attachment_filenames: Vec::new(),
        });
        s.footer.input_tokens = 100;
        s.footer.output_tokens = 50;
        s.footer.turn_count = 1;
        update(
            &mut s,
            capo_agent::UiEvent::SessionReplaced {
                session_id: "test".into(),
                history: Vec::new(),
            }
            .into(),
        );
        assert!(s.messages.is_empty());
        assert_eq!(s.footer.input_tokens, 0);
        assert_eq!(s.footer.output_tokens, 0);
        assert_eq!(s.footer.turn_count, 0);
    }

    #[test]
    fn turn_stats_updates_footer_cumulative() {
        let mut s = AppState::default();
        let cmds = update(
            &mut s,
            Event::Ui(UiEvent::TurnStats {
                input_tokens: 100,
                output_tokens: 25,
                cumulative_input: 1100,
                cumulative_output: 275,
                model: "claude-opus-4-7".into(),
            }),
        );
        assert!(cmds.is_empty());
        assert_eq!(s.footer.input_tokens, 1100);
        assert_eq!(s.footer.output_tokens, 275);
        assert_eq!(s.footer.model, "claude-opus-4-7");
        assert_eq!(s.footer.turn_count, 1);
    }

    #[test]
    fn settings_snapshot_updates_settings_and_resets_footer_cost_state() {
        let mut s = AppState::default();
        s.footer.show_cost = true;
        s.footer.input_tokens = 1100;
        s.footer.output_tokens = 275;
        s.footer.turn_count = 2;
        s.footer.model = "old-model".into();

        let mut settings = capo_agent::Settings::default();
        settings.ui.footer_show_cost = false;
        settings.model.name = "new-model".into();
        let cmds = update(
            &mut s,
            Event::Ui(UiEvent::SettingsSnapshot {
                settings: settings.clone(),
            }),
        );

        assert!(cmds.is_empty());
        assert!(!s.footer.show_cost);
        assert_eq!(s.footer.input_tokens, 0);
        assert_eq!(s.footer.output_tokens, 0);
        assert_eq!(s.footer.turn_count, 0);
        assert_eq!(s.footer.model, "new-model");
        assert_eq!(s.settings_snapshot, Some(settings));
    }

    #[test]
    fn model_switched_event_updates_footer_settings_snapshot_and_adds_notice() {
        let mut s = AppState::default();
        let mut settings = capo_agent::Settings::default();
        settings.model.name = "old-model".into();
        s.settings_snapshot = Some(settings.clone());
        let mut editor = crate::settings_editor::SettingsState::new(settings);
        let Some(model_name_idx) = editor.rows.iter().position(|row| {
            matches!(
                row,
                crate::settings_editor::SettingsRow::Field {
                    section: "model",
                    key: "name",
                    ..
                }
            )
        }) else {
            panic!("model.name row");
        };
        editor.selected = model_name_idx;
        editor.edit = Some(crate::settings_editor::EditCursor::Text {
            buffer: "old-model".into(),
            cursor_byte: "old-model".len(),
        });
        s.bottom_pane = Some(BottomPane::Settings(editor));
        s.footer.model = "old-model".into();
        update(
            &mut s,
            capo_agent::UiEvent::ModelSwitched(capo_agent::ModelId::from("new-model")).into(),
        );
        assert_eq!(s.footer.model, "new-model");
        assert_eq!(
            s.settings_snapshot.as_ref().map(|s| s.model.name.as_str()),
            Some("new-model")
        );
        let Some(BottomPane::Settings(editor)) = s.bottom_pane.as_ref() else {
            panic!("settings editor should remain open");
        };
        assert_eq!(editor.original.model.name, "new-model");
        assert!(editor.rows.iter().any(|row| matches!(
            row,
            crate::settings_editor::SettingsRow::Field {
                section: "model",
                key: "name",
                value: crate::settings_editor::SettingsValue::String(value),
                ..
            } if value == "new-model"
        )));
        assert!(matches!(
            editor.edit.as_ref(),
            Some(crate::settings_editor::EditCursor::Text { buffer, cursor_byte })
                if buffer == "new-model" && *cursor_byte == "new-model".len()
        ));
        assert!(matches!(
            s.messages.last(),
            Some(MessageBlock::Notice { title, body })
                if title == "model" && body.contains("new-model")
        ));
    }

    #[test]
    fn empty_message_complete_does_not_add_block() {
        let mut s = AppState::default();
        update(&mut s, UiEvent::AgentMessageComplete(String::new()).into());
        assert!(s.messages.is_empty());
    }

    #[test]
    fn tool_progress_after_start_transitions_to_running_and_appends_stdout() {
        let mut s = AppState::default();
        update(
            &mut s,
            UiEvent::ToolCallStarted {
                id: "tool_1".into(),
                name: "bash".into(),
                args: serde_json::json!({"command": "echo hi"}),
            }
            .into(),
        );
        update(
            &mut s,
            UiEvent::ToolCallProgress {
                id: "tool_1".into(),
                chunk: capo_agent::ProgressChunk::Stdout(b"hello".to_vec()),
            }
            .into(),
        );

        match &s.in_flight {
            Some(InFlight::ToolRunning {
                id,
                name,
                stdout,
                stderr,
            }) => {
                assert_eq!(id, "tool_1");
                assert_eq!(name, "bash");
                assert_eq!(stdout, "hello");
                assert!(stderr.is_empty());
            }
            other => panic!("{other:?}"),
        }
    }

    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    fn key(code: KeyCode, mods: KeyModifiers) -> Event {
        Event::Input(InputEvent::Key(KeyEvent::new(code, mods)))
    }

    #[test]
    fn app_state_default_permission_mode_is_prompt() {
        let s = AppState::default();
        assert_eq!(s.permission_mode, capo_agent::PermissionMode::Prompt);
    }

    #[test]
    fn shift_tab_emits_set_permission_mode_command_with_next() {
        let mut s = AppState::default();
        let cmds = update(&mut s, key(KeyCode::Tab, KeyModifiers::SHIFT));
        assert_eq!(cmds.len(), 1);
        match &cmds[0] {
            capo_agent::Command::SetPermissionMode { mode } => {
                assert_eq!(
                    *mode,
                    capo_agent::PermissionMode::AcceptEdits,
                    "next from Prompt"
                );
            }
            other => panic!("expected SetPermissionMode, got {other:?}"),
        }
        assert_eq!(s.permission_mode, capo_agent::PermissionMode::Prompt);
    }

    #[test]
    fn shift_tab_ignored_when_bottom_pane_open() {
        let mut s = AppState {
            bottom_pane: Some(BottomPane::Palette(crate::slash::PaletteState::default())),
            ..AppState::default()
        };
        let cmds = update(&mut s, key(KeyCode::Tab, KeyModifiers::SHIFT));
        assert!(
            cmds.is_empty(),
            "Shift+Tab should not fire when BottomPane open"
        );
    }

    #[test]
    fn permission_mode_changed_event_updates_state() {
        let mut s = AppState::default();
        let cmds = update(
            &mut s,
            Event::Ui(capo_agent::UiEvent::PermissionModeChanged {
                mode: capo_agent::PermissionMode::Bypass,
            }),
        );
        assert!(cmds.is_empty());
        assert_eq!(s.permission_mode, capo_agent::PermissionMode::Bypass);
    }

    #[test]
    fn f3_enters_nav_mode_when_not_already_in() {
        let mut s = AppState::default();
        s.messages.push(MessageBlock::Assistant {
            text: "hi".into(),
            collapsed: false,
        });

        let cmds = update(&mut s, key(KeyCode::F(3), KeyModifiers::NONE));
        assert!(cmds.is_empty());
        assert!(s.nav_mode, "F3 should set nav_mode = true");
        assert_eq!(
            s.nav_selected,
            Some(0),
            "F3 should select last visible block"
        );
    }

    #[test]
    fn f3_exits_nav_mode_when_already_in() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(0),
            ..AppState::default()
        };

        let cmds = update(&mut s, key(KeyCode::F(3), KeyModifiers::NONE));
        assert!(cmds.is_empty());
        assert!(!s.nav_mode, "F3 toggles nav_mode = false");
    }

    #[test]
    fn f3_ignored_when_bottom_pane_open() {
        let mut s = AppState {
            bottom_pane: Some(BottomPane::Palette(crate::slash::PaletteState::default())),
            ..AppState::default()
        };

        let _ = update(&mut s, key(KeyCode::F(3), KeyModifiers::NONE));
        assert!(
            !s.nav_mode,
            "F3 with BottomPane open should NOT enter nav mode"
        );
        assert!(s.bottom_pane.is_some(), "BottomPane should remain open");
    }

    fn pane_with_three_assistant_blocks() -> AppState {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(2),
            ..AppState::default()
        };
        for i in 0..3 {
            s.messages.push(MessageBlock::Assistant {
                text: format!("msg {i}"),
                collapsed: false,
            });
        }
        s
    }

    #[test]
    fn nav_up_arrow_decrements_selection() {
        let mut s = pane_with_three_assistant_blocks();
        let _ = update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(1));
    }

    #[test]
    fn nav_down_arrow_increments_selection() {
        let mut s = AppState {
            nav_selected: Some(0),
            ..pane_with_three_assistant_blocks()
        };
        let _ = update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(1));
    }

    #[test]
    fn nav_k_vim_alias_moves_up() {
        let mut s = pane_with_three_assistant_blocks();
        let _ = update(&mut s, key(KeyCode::Char('k'), KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(1));
    }

    #[test]
    fn nav_j_vim_alias_moves_down() {
        let mut s = AppState {
            nav_selected: Some(0),
            ..pane_with_three_assistant_blocks()
        };
        let _ = update(&mut s, key(KeyCode::Char('j'), KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(1));
    }

    #[test]
    fn nav_esc_exits_nav_mode() {
        let mut s = pane_with_three_assistant_blocks();
        let _ = update(&mut s, key(KeyCode::Esc, KeyModifiers::NONE));
        assert!(!s.nav_mode, "Esc should exit nav mode");
    }

    #[test]
    fn nav_up_at_top_stays_at_zero() {
        let mut s = AppState {
            nav_selected: Some(0),
            ..pane_with_three_assistant_blocks()
        };
        let _ = update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(0), "Up at top should saturate");
    }

    #[test]
    fn nav_down_at_bottom_stays_at_last() {
        let mut s = pane_with_three_assistant_blocks();
        let _ = update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(2), "Down at bottom should saturate");
    }

    #[test]
    fn nav_space_toggles_assistant_collapsed() {
        let mut s = AppState {
            nav_selected: Some(1),
            ..pane_with_three_assistant_blocks()
        };

        let _ = update(&mut s, key(KeyCode::Char(' '), KeyModifiers::NONE));
        match &s.messages[1] {
            MessageBlock::Assistant { collapsed, .. } => {
                assert!(*collapsed, "Space should toggle collapsed = true");
            }
            _ => panic!("expected Assistant"),
        }

        let _ = update(&mut s, key(KeyCode::Char(' '), KeyModifiers::NONE));
        match &s.messages[1] {
            MessageBlock::Assistant { collapsed, .. } => {
                assert!(!*collapsed, "second Space should toggle back to false");
            }
            _ => panic!("expected Assistant"),
        }
    }

    #[test]
    fn nav_space_toggles_thinking_collapsed() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(0),
            ..AppState::default()
        };
        s.messages.push(MessageBlock::Thinking {
            text: "reasoning...".into(),
            collapsed: false,
        });

        let _ = update(&mut s, key(KeyCode::Char(' '), KeyModifiers::NONE));
        match &s.messages[0] {
            MessageBlock::Thinking { collapsed, .. } => assert!(*collapsed),
            _ => panic!("expected Thinking"),
        }
    }

    #[test]
    fn nav_space_toggles_toolcall_collapsed() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(0),
            ..AppState::default()
        };
        s.messages.push(MessageBlock::ToolCall {
            id: "t1".into(),
            name: "read".into(),
            args: serde_json::json!({"path": "x"}),
            collapsed: false,
        });
        let mut settings = capo_agent::Settings::default();
        settings.ui.collapse_tool_args_max_lines = 0;
        s.settings_snapshot = Some(settings);

        let _ = update(&mut s, key(KeyCode::Char(' '), KeyModifiers::NONE));
        match &s.messages[0] {
            MessageBlock::ToolCall { collapsed, .. } => assert!(*collapsed),
            _ => panic!("expected ToolCall"),
        }
    }

    #[test]
    fn nav_space_on_user_block_is_noop() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(0),
            ..AppState::default()
        };
        s.messages.push(MessageBlock::User {
            text: "question".into(),
            attachment_filenames: vec![],
        });

        let _ = update(&mut s, key(KeyCode::Char(' '), KeyModifiers::NONE));
        assert!(matches!(s.messages[0], MessageBlock::User { .. }));
    }

    #[test]
    fn nav_g_then_g_within_1s_jumps_to_top() {
        let mut s = pane_with_three_assistant_blocks();
        s.nav_selected = Some(2);
        let _ = update(&mut s, key(KeyCode::Char('g'), KeyModifiers::NONE));
        assert!(s.nav_g_pending_at.is_some(), "first g sets pending");
        let _ = update(&mut s, key(KeyCode::Char('g'), KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(0), "gg should jump to first block");
        assert!(
            s.nav_g_pending_at.is_none(),
            "pending should clear after jump"
        );
    }

    #[test]
    fn nav_capital_g_jumps_to_bottom() {
        let mut s = AppState {
            nav_selected: Some(0),
            ..pane_with_three_assistant_blocks()
        };
        let _ = update(&mut s, key(KeyCode::Char('G'), KeyModifiers::SHIFT));
        assert_eq!(s.nav_selected, Some(2));
    }

    #[test]
    fn nav_g_followed_by_other_key_clears_pending() {
        let mut s = pane_with_three_assistant_blocks();
        s.nav_selected = Some(2);
        let _ = update(&mut s, key(KeyCode::Char('g'), KeyModifiers::NONE));
        let _ = update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        assert!(s.nav_g_pending_at.is_none(), "non-g key clears pending");

        let _ = update(&mut s, key(KeyCode::Char('g'), KeyModifiers::NONE));
        assert!(s.nav_g_pending_at.is_some(), "second g sets new pending");
    }

    #[test]
    fn nav_gg_with_stale_pending_does_not_jump() {
        let mut s = pane_with_three_assistant_blocks();
        s.nav_selected = Some(2);
        s.nav_g_pending_at =
            std::time::Instant::now().checked_sub(std::time::Duration::from_secs(2));

        let _ = update(&mut s, key(KeyCode::Char('g'), KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(2), "stale pending: g does not jump");
        assert!(
            s.nav_g_pending_at.is_some(),
            "stale pending replaced with fresh"
        );
    }

    #[test]
    fn nav_skips_hidden_thinking_blocks() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(0),
            ..AppState::default()
        };
        s.messages.push(MessageBlock::Assistant {
            text: "a".into(),
            collapsed: false,
        });
        s.messages.push(MessageBlock::Thinking {
            text: "hidden".into(),
            collapsed: false,
        });
        s.messages.push(MessageBlock::Assistant {
            text: "c".into(),
            collapsed: false,
        });

        let mut settings = capo_agent::settings::Settings::default();
        settings.ui.hide_thinking_blocks = true;
        s.settings_snapshot = Some(settings);

        let _ = update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(
            s.nav_selected,
            Some(2),
            "nav should skip hidden Thinking block"
        );
    }

    #[test]
    fn nav_capital_g_scrolls_down_when_selected_below_viewport() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(0),
            scroll_lines_from_bottom: 20,
            ..AppState::default()
        };
        for i in 0..50 {
            s.messages.push(MessageBlock::Assistant {
                text: format!("block {i}"),
                collapsed: false,
            });
        }
        s.last_messages_viewport.set(Some((80, 10)));

        let _ = update(&mut s, key(KeyCode::Char('G'), KeyModifiers::SHIFT));

        assert_eq!(s.nav_selected, Some(49));
        assert_eq!(
            s.scroll_lines_from_bottom, 0,
            "G should scroll down to show the last block when current viewport is above it"
        );
    }

    #[test]
    fn nav_up_scrolls_into_view_when_selected_above_viewport() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(49),
            scroll_lines_from_bottom: 0,
            ..AppState::default()
        };
        for i in 0..50 {
            s.messages.push(MessageBlock::Assistant {
                text: format!("block {i}"),
                collapsed: false,
            });
        }
        s.last_messages_viewport.set(Some((80, 10)));

        for _ in 0..15 {
            let _ = update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        }

        assert!(
            s.scroll_lines_from_bottom > 0,
            "scroll should advance to bring selected into view; got {}",
            s.scroll_lines_from_bottom
        );
    }

    #[test]
    fn nav_navigation_keeps_scroll_when_selected_on_screen() {
        let mut s = AppState {
            scroll_lines_from_bottom: 0,
            nav_selected: Some(2),
            ..pane_with_three_assistant_blocks()
        };
        s.last_messages_viewport.set(Some((80, 50)));

        let _ = update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(
            s.scroll_lines_from_bottom, 0,
            "no scroll change when selected remains on-screen"
        );
    }

    #[test]
    fn nav_space_on_default_collapsed_tool_result_forces_expanded() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(0),
            ..AppState::default()
        };
        s.messages.push(MessageBlock::ToolResult {
            id: "t1".into(),
            result: capo_agent::UiToolResult {
                is_error: false,
                text: "line 1\nline 2".into(),
            },
            collapsed: false,
        });
        s.settings_snapshot = Some(capo_agent::Settings::default());

        let _ = update(&mut s, key(KeyCode::Char(' '), KeyModifiers::NONE));
        assert_eq!(s.collapse_overrides.get(&0), Some(&false));
        match &s.messages[0] {
            MessageBlock::ToolResult { collapsed, .. } => assert!(!*collapsed),
            _ => panic!("expected ToolResult"),
        }
    }

    #[test]
    fn session_replaced_normalizes_stale_nav_selection() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(99),
            ..pane_with_three_assistant_blocks()
        };
        let msgs = vec![capo_agent::Message::assistant("new")];

        update(
            &mut s,
            capo_agent::UiEvent::SessionReplaced {
                session_id: "test".into(),
                history: msgs,
            }
            .into(),
        );
        assert_eq!(s.nav_selected, Some(0));

        let _ = update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(s.nav_selected, Some(0));
    }

    #[test]
    fn settings_snapshot_hiding_selected_block_snaps_to_previous_visible() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(1),
            ..AppState::default()
        };
        s.messages.push(MessageBlock::Assistant {
            text: "before".into(),
            collapsed: false,
        });
        s.messages.push(MessageBlock::Thinking {
            text: "selected thinking".into(),
            collapsed: false,
        });
        s.messages.push(MessageBlock::Assistant {
            text: "after".into(),
            collapsed: false,
        });
        let mut settings = capo_agent::Settings::default();
        settings.ui.hide_thinking_blocks = true;

        update(&mut s, Event::Ui(UiEvent::SettingsSnapshot { settings }));
        assert_eq!(s.nav_selected, Some(0));
    }

    #[test]
    fn ctrl_e_normalizes_nav_selection_when_thinking_becomes_hidden() {
        let mut settings = capo_agent::Settings::default();
        settings.ui.hide_thinking_blocks = true;
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(1),
            expanded_all: true,
            settings_snapshot: Some(settings),
            ..AppState::default()
        };
        s.messages.push(MessageBlock::Assistant {
            text: "before".into(),
            collapsed: false,
        });
        s.messages.push(MessageBlock::Thinking {
            text: "selected thinking".into(),
            collapsed: false,
        });

        update(&mut s, key(KeyCode::Char('e'), KeyModifiers::CONTROL));
        assert!(!s.expanded_all);
        assert_eq!(s.nav_selected, Some(0));
    }

    #[test]
    fn settings_snapshot_hiding_selected_block_snaps_to_nearest_visible() {
        let mut s = AppState {
            nav_mode: true,
            nav_selected: Some(2),
            ..AppState::default()
        };
        s.messages.push(MessageBlock::Assistant {
            text: "far before".into(),
            collapsed: false,
        });
        s.messages.push(MessageBlock::Thinking {
            text: "also hidden".into(),
            collapsed: false,
        });
        s.messages.push(MessageBlock::Thinking {
            text: "selected hidden".into(),
            collapsed: false,
        });
        s.messages.push(MessageBlock::Assistant {
            text: "near after".into(),
            collapsed: false,
        });
        let mut settings = capo_agent::Settings::default();
        settings.ui.hide_thinking_blocks = true;

        update(&mut s, Event::Ui(UiEvent::SettingsSnapshot { settings }));
        assert_eq!(s.nav_selected, Some(3));
    }

    fn make_settings_pane(state: &mut AppState) {
        state.bottom_pane = Some(BottomPane::Settings(
            crate::settings_editor::SettingsState::new(capo_agent::Settings::default()),
        ));
    }

    #[test]
    fn settings_pane_down_arrow_navigates_to_next_field() {
        let mut s = AppState::default();
        make_settings_pane(&mut s);
        let initial = match &s.bottom_pane {
            Some(BottomPane::Settings(state)) => state.selected,
            _ => unreachable!(),
        };
        let _ = update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        let after = match &s.bottom_pane {
            Some(BottomPane::Settings(state)) => state.selected,
            _ => unreachable!(),
        };
        assert!(after > initial, "Down should advance selection");
    }

    #[test]
    fn settings_pane_down_arrow_skips_section_header_rows() {
        let mut s = AppState::default();
        make_settings_pane(&mut s);
        for _ in 0..3 {
            let _ = update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        }
        let selected_row = match &s.bottom_pane {
            Some(BottomPane::Settings(state)) => &state.rows[state.selected],
            _ => unreachable!(),
        };
        assert!(matches!(
            selected_row,
            crate::settings_editor::SettingsRow::Field { .. }
        ));
    }

    #[test]
    fn settings_pane_esc_in_list_mode_closes_pane_and_discards() {
        let mut s = AppState::default();
        make_settings_pane(&mut s);
        let _ = update(&mut s, key(KeyCode::Esc, KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none(), "Esc should close pane");
    }

    #[test]
    fn settings_pane_f2_save_emits_apply_settings_for_clean_defaults() {
        let mut s = AppState::default();
        make_settings_pane(&mut s);
        let cmds = update(&mut s, key(KeyCode::F(2), KeyModifiers::NONE));
        assert_eq!(cmds.len(), 1);
        assert!(matches!(cmds[0], capo_agent::Command::ApplySettings { .. }));
        assert!(s.bottom_pane.is_none(), "save should close pane");
    }

    #[test]
    fn settings_pane_enter_validates_text_inline() {
        let mut s = AppState::default();
        make_settings_pane(&mut s);

        if let Some(BottomPane::Settings(state)) = s.bottom_pane.as_mut() {
            let name_idx = state
                .rows
                .iter()
                .position(|r| {
                    matches!(
                        r,
                        crate::settings_editor::SettingsRow::Field {
                            section: "model",
                            key: "name",
                            ..
                        }
                    )
                })
                .unwrap_or_else(|| panic!("model.name field exists"));
            state.selected = name_idx;
        }

        let _ = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        for _ in 0.."claude-sonnet-4-6".len() {
            let _ = update(&mut s, key(KeyCode::Backspace, KeyModifiers::NONE));
        }
        let _ = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));

        let Some(BottomPane::Settings(state)) = &s.bottom_pane else {
            unreachable!();
        };
        assert!(state.errors.contains_key(&state.selected));
        assert!(
            state.edit.is_some(),
            "invalid inline edit should remain active"
        );
    }

    #[test]
    fn settings_pane_f2_commits_enum_edit_before_save() {
        let mut s = AppState::default();
        make_settings_pane(&mut s);

        let provider_idx = if let Some(BottomPane::Settings(state)) = s.bottom_pane.as_mut() {
            let idx = state
                .rows
                .iter()
                .position(|r| {
                    matches!(
                        r,
                        crate::settings_editor::SettingsRow::Field {
                            section: "model",
                            key: "provider",
                            ..
                        }
                    )
                })
                .unwrap_or_else(|| panic!("provider field exists"));
            state.selected = idx;
            idx
        } else {
            unreachable!()
        };

        let _ = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        let _ = update(&mut s, key(KeyCode::Right, KeyModifiers::NONE));
        let cmds = update(&mut s, key(KeyCode::F(2), KeyModifiers::NONE));

        assert!(s.bottom_pane.is_none(), "save should close pane");
        match &cmds[..] {
            [capo_agent::Command::ApplySettings { settings }] => {
                assert_eq!(settings.model.provider, "openai");
            }
            other => panic!("expected ApplySettings; got {other:?}"),
        }
        assert_eq!(provider_idx, 1);
    }

    #[test]
    fn settings_pane_space_toggles_bool_field() {
        let mut s = AppState::default();
        make_settings_pane(&mut s);

        if let Some(BottomPane::Settings(state)) = s.bottom_pane.as_mut() {
            let bool_idx = state
                .rows
                .iter()
                .position(|r| {
                    matches!(
                        r,
                        crate::settings_editor::SettingsRow::Field {
                            kind: crate::settings_editor::FieldKind::Bool,
                            ..
                        }
                    )
                })
                .unwrap_or_else(|| panic!("Bool field exists"));
            state.selected = bool_idx;
        }

        let original_value = match &s.bottom_pane {
            Some(BottomPane::Settings(state)) => match &state.rows[state.selected] {
                crate::settings_editor::SettingsRow::Field {
                    value: crate::settings_editor::SettingsValue::Bool(v),
                    ..
                } => *v,
                _ => unreachable!(),
            },
            _ => unreachable!(),
        };

        let _ = update(&mut s, key(KeyCode::Char(' '), KeyModifiers::NONE));

        let after = match &s.bottom_pane {
            Some(BottomPane::Settings(state)) => match &state.rows[state.selected] {
                crate::settings_editor::SettingsRow::Field {
                    value: crate::settings_editor::SettingsValue::Bool(v),
                    ..
                } => *v,
                _ => unreachable!(),
            },
            _ => unreachable!(),
        };

        assert_ne!(original_value, after, "Space should toggle bool");
    }

    fn state_with_fork_candidates() -> AppState {
        AppState {
            fork_candidates: vec![
                crate::state::ForkCandidate {
                    id: "e3".into(),
                    preview: "third".into(),
                },
                crate::state::ForkCandidate {
                    id: "e1".into(),
                    preview: "first".into(),
                },
            ],
            ..Default::default()
        }
    }

    fn state_with_branch_tree() -> AppState {
        use capo_agent::{BranchNode, BranchTree};
        AppState {
            branch_tree: Some(BranchTree {
                nodes: vec![
                    BranchNode {
                        id: "r".into(),
                        parent: None,
                        children: vec![1],
                        label: "one".into(),
                    },
                    BranchNode {
                        id: "a".into(),
                        parent: Some(0),
                        children: vec![],
                        label: "two".into(),
                    },
                ],
                root: Some(0),
                active_leaf: Some(1),
            }),
            ..Default::default()
        }
    }

    #[test]
    fn slash_tree_opens_branch_tree_pane() {
        let mut s = state_with_branch_tree();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "tree".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(matches!(s.bottom_pane, Some(BottomPane::BranchTree(_))));
    }

    #[test]
    fn branch_tree_enter_arms_pending_fork_with_selected_node_id() {
        let mut s = state_with_branch_tree();
        s.bottom_pane = Some(BottomPane::BranchTree(
            crate::state::BranchTreeState::default(),
        ));
        // selected = 0 → node "r".
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(cmds.is_empty(), "picking a node only arms the fork");
        assert!(s.bottom_pane.is_none());
        assert_eq!(s.pending_fork.as_deref(), Some("r"));
    }

    #[test]
    fn branch_tree_down_then_enter_picks_second_node() {
        let mut s = state_with_branch_tree();
        s.bottom_pane = Some(BottomPane::BranchTree(
            crate::state::BranchTreeState::default(),
        ));
        update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(s.pending_fork.as_deref(), Some("a"));
    }

    #[test]
    fn branch_tree_esc_closes_without_arming() {
        let mut s = state_with_branch_tree();
        s.bottom_pane = Some(BottomPane::BranchTree(
            crate::state::BranchTreeState::default(),
        ));
        update(&mut s, key(KeyCode::Esc, KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none());
        assert!(s.pending_fork.is_none());
    }

    #[test]
    fn branch_tree_selection_clamps_to_visible_rows() {
        use capo_agent::{BranchNode, BranchTree};
        let mut s = AppState {
            branch_tree: Some(BranchTree {
                nodes: (0..12)
                    .map(|i| BranchNode {
                        id: format!("n{i}"),
                        parent: if i > 0 { Some(i - 1) } else { None },
                        children: if i < 11 { vec![i + 1] } else { vec![] },
                        label: format!("node {i}"),
                    })
                    .collect(),
                root: Some(0),
                active_leaf: Some(11),
            }),
            bottom_pane: Some(BottomPane::BranchTree(
                crate::state::BranchTreeState::default(),
            )),
            ..Default::default()
        };
        for _ in 0..20 {
            update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        }
        update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(s.pending_fork.as_deref(), Some("n9"));
    }

    #[test]
    fn slash_fork_opens_fork_picker() {
        let mut s = state_with_fork_candidates();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "fork".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(matches!(s.bottom_pane, Some(BottomPane::ForkPicker(_))));
    }

    #[test]
    fn fork_picker_enter_sets_pending_fork_and_closes() {
        let mut s = state_with_fork_candidates();
        s.bottom_pane = Some(BottomPane::ForkPicker(
            crate::state::ForkPickerState::default(),
        ));
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(
            cmds.is_empty(),
            "picking only arms the fork; no command yet"
        );
        assert!(s.bottom_pane.is_none());
        assert_eq!(
            s.pending_fork.as_deref(),
            Some("e3"),
            "selected the first (newest) row"
        );
    }

    #[test]
    fn enter_with_pending_fork_emits_fork_from_and_clears_it() {
        let mut s = state_with_fork_candidates();
        s.pending_fork = Some("e1".into());
        s.editor.text = "branched message".into();
        s.editor.cursor_byte = s.editor.text.len();
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(
            cmds,
            vec![Command::ForkFrom {
                from: "e1".into(),
                message: "branched message".into()
            }]
        );
        assert!(s.pending_fork.is_none(), "pending fork consumed");
        assert!(s.editor.text.is_empty());
    }

    #[test]
    fn fork_picker_esc_closes_without_arming() {
        let mut s = state_with_fork_candidates();
        s.bottom_pane = Some(BottomPane::ForkPicker(
            crate::state::ForkPickerState::default(),
        ));
        update(&mut s, key(KeyCode::Esc, KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none());
        assert!(s.pending_fork.is_none());
    }

    #[test]
    fn branch_tree_event_populates_state() {
        use capo_agent::{BranchNode, BranchTree};
        let mut s = AppState::default();
        let tree = BranchTree {
            nodes: vec![BranchNode {
                id: "r".into(),
                parent: None,
                children: vec![],
                label: "x".into(),
            }],
            root: Some(0),
            active_leaf: Some(0),
        };
        update(&mut s, capo_agent::UiEvent::BranchTree(tree).into());
        let Some(branch_tree) = s.branch_tree.as_ref() else {
            panic!("branch tree should be populated");
        };
        assert_eq!(branch_tree.nodes.len(), 1);
    }

    #[test]
    fn fork_candidates_event_populates_state() {
        let mut s = AppState::default();
        update(
            &mut s,
            capo_agent::UiEvent::ForkCandidates(vec![
                ("e2".into(), "second".into()),
                ("e1".into(), "first".into()),
            ])
            .into(),
        );
        assert_eq!(s.fork_candidates.len(), 2);
        assert_eq!(s.fork_candidates[0].id, "e2");
        assert_eq!(s.fork_candidates[0].preview, "second");
    }

    #[test]
    fn arrow_keys_drive_scroll_lines_from_bottom_one_row_at_a_time() {
        // Wheel events arrive as Up/Down via DECSET 1007 alt-scroll. The
        // one-row step keeps the cadence matching a single wheel detent.
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 1);
        update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 3);
        update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 2);
        // Saturating sub at 0 = stays at auto-follow.
        for _ in 0..5 {
            update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        }
        assert_eq!(s.scroll_lines_from_bottom, 0);
    }

    #[test]
    fn pgup_pgdn_home_end_drive_scroll_lines_from_bottom() {
        let mut s = AppState::default();
        assert_eq!(s.scroll_lines_from_bottom, 0);

        // PgUp increments by 10 (half-page).
        update(&mut s, key(KeyCode::PageUp, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 10);
        update(&mut s, key(KeyCode::PageUp, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 20);

        // PgDn decrements, saturating at 0 = resume auto-follow.
        update(&mut s, key(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 10);
        update(&mut s, key(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 0);
        update(&mut s, key(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 0, "PgDn at 0 stays at 0");

        // Home jumps to large sentinel; render-side saturating_sub clamps
        // to the actual top.
        update(&mut s, key(KeyCode::Home, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, u16::MAX);

        // End resets to auto-follow.
        update(&mut s, key(KeyCode::End, KeyModifiers::NONE));
        assert_eq!(s.scroll_lines_from_bottom, 0);
    }

    #[test]
    fn enter_on_idle_sends_user_message_and_clears_editor() {
        let mut s = AppState::default();
        s.editor.text = "hello".into();
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(
            cmds,
            vec![Command::SendUserMessage {
                text: "hello".into(),
                attachments: Vec::new(),
            }]
        );
        assert!(s.editor.text.is_empty());
        assert!(matches!(
            s.messages.last(),
            Some(MessageBlock::User { text: t, attachment_filenames: _ }) if t == "hello"
        ));
    }

    #[test]
    fn enter_with_slash_image_real_file_produces_attachment_command_and_user_block() {
        use std::io::Write;

        // Create a real PNG-shaped tempfile so existence + extension
        // checks both pass.
        let mut path = std::env::temp_dir();
        path.push(format!("capo-v06-tui-enter-{}.png", std::process::id()));
        std::fs::File::create(&path)
            .unwrap_or_else(|e| panic!("create tempfile: {e}"))
            .write_all(&[0x89, 0x50, 0x4E, 0x47])
            .unwrap_or_else(|e| panic!("write tempfile: {e}"));

        let mut s = AppState::default();
        s.editor.text = format!("/image {}", path.to_string_lossy());
        s.editor.cursor_byte = s.editor.text.len();

        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));

        // Exactly one Command::SendUserMessage with the attachment.
        assert_eq!(cmds.len(), 1);
        match &cmds[0] {
            Command::SendUserMessage { text, attachments } => {
                assert_eq!(text, "");
                assert_eq!(attachments.len(), 1);
                assert!(matches!(
                    &attachments[0],
                    capo_agent::Attachment::Image { path: p } if p == &path
                ));
            }
            other => panic!("expected SendUserMessage; got {other:?}"),
        }

        // The user-message block carries the filename for the transcript
        // marker.
        let last = s.messages.last().unwrap_or_else(|| panic!("user message"));
        match last {
            MessageBlock::User {
                attachment_filenames,
                ..
            } => {
                assert_eq!(attachment_filenames.len(), 1);
                assert!(attachment_filenames[0].ends_with(".png"));
            }
            other => panic!("expected User block; got {other:?}"),
        }
    }

    #[test]
    fn attachment_error_after_slash_image_restores_buffer_and_removes_user_row() {
        let mut s = AppState::default();
        s.editor.text = "/image /tmp/definitely-missing-capo-review.png describe".into();
        s.editor.cursor_byte = s.editor.text.len();

        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(cmds.len(), 1);
        assert!(matches!(cmds[0], Command::SendUserMessage { .. }));
        assert!(matches!(s.messages.last(), Some(MessageBlock::User { .. })));
        assert!(s.editor.text.is_empty());

        let restore_cmds = update(
            &mut s,
            UiEvent::AttachmentError {
                kind: capo_agent::AttachmentErrorKind::NotFound,
                message: "image not found".into(),
            }
            .into(),
        );

        assert!(restore_cmds.is_empty());
        assert_eq!(
            s.editor.text,
            "/image /tmp/definitely-missing-capo-review.png describe"
        );
        assert_eq!(s.editor.cursor_byte, s.editor.text.len());
        assert_eq!(s.messages.len(), 1);
        assert!(
            matches!(s.messages.last(), Some(MessageBlock::Error(t)) if t == "image not found")
        );
    }

    #[test]
    fn second_attachment_submit_is_blocked_until_first_validates_or_errors() {
        let mut s = AppState::default();
        s.editor.text = "/image /tmp/capo-review-missing-one.png first".into();
        s.editor.cursor_byte = s.editor.text.len();

        let first_cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(first_cmds.len(), 1);
        assert!(matches!(first_cmds[0], Command::SendUserMessage { .. }));
        assert!(matches!(s.in_flight, Some(InFlight::Thinking)));

        s.editor.text = "/image /tmp/capo-review-missing-two.png second".into();
        s.editor.cursor_byte = s.editor.text.len();

        let second_cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));

        assert!(second_cmds.is_empty());
        assert_eq!(
            s.editor.text,
            "/image /tmp/capo-review-missing-two.png second"
        );
        assert_eq!(
            s.messages
                .iter()
                .filter(|m| matches!(m, MessageBlock::User { .. }))
                .count(),
            1
        );
    }

    #[test]
    fn enter_with_pending_fork_drops_attachment_markers_because_fork_command_has_no_attachments() {
        use std::io::Write;

        let mut path = std::env::temp_dir();
        path.push(format!("capo-v06-tui-fork-{}.png", std::process::id()));
        std::fs::File::create(&path)
            .unwrap_or_else(|e| panic!("create tempfile: {e}"))
            .write_all(&[0x89, 0x50, 0x4E, 0x47])
            .unwrap_or_else(|e| panic!("write tempfile: {e}"));

        let mut s = state_with_fork_candidates();
        s.pending_fork = Some("e1".into());
        s.editor.text = format!("/image {} fork text", path.to_string_lossy());
        s.editor.cursor_byte = s.editor.text.len();

        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));

        assert_eq!(
            cmds,
            vec![Command::ForkFrom {
                from: "e1".into(),
                message: "fork text".into(),
            }]
        );
        assert!(matches!(
            s.messages.last(),
            Some(MessageBlock::User { text, attachment_filenames })
                if text == "fork text" && attachment_filenames.is_empty()
        ));
    }

    #[test]
    fn enter_with_leading_path_token_auto_detects_attachment() {
        use std::io::Write;

        let mut path = std::env::temp_dir();
        path.push(format!(
            "capo-v06-tui-autodetect-{}.png",
            std::process::id(),
        ));
        std::fs::File::create(&path)
            .unwrap_or_else(|e| panic!("create tempfile: {e}"))
            .write_all(&[0x89, 0x50, 0x4E, 0x47])
            .unwrap_or_else(|e| panic!("write tempfile: {e}"));

        let mut s = AppState::default();
        s.editor.text = format!("{} what is this", path.to_string_lossy());
        s.editor.cursor_byte = s.editor.text.len();

        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));

        assert_eq!(cmds.len(), 1);
        match &cmds[0] {
            Command::SendUserMessage { text, attachments } => {
                assert_eq!(text, "what is this");
                assert_eq!(attachments.len(), 1);
            }
            other => panic!("expected SendUserMessage; got {other:?}"),
        }
    }

    #[test]
    fn enter_with_malformed_slash_image_pushes_notice_and_keeps_buffer() {
        let mut s = AppState::default();
        s.editor.text = "/image hello world".into();
        s.editor.cursor_byte = s.editor.text.len();

        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));

        // No command emitted — turn does not start.
        assert!(cmds.is_empty(), "expected no command; got {cmds:?}");

        // Notice block surfaced.
        assert!(
            matches!(
                s.messages.last(),
                Some(MessageBlock::Notice { title, body })
                    if title == "/image" && body.contains("at least one path")
            ),
            "expected /image Notice; got {:?}",
            s.messages.last()
        );

        // Buffer retained so user can edit.
        assert_eq!(s.editor.text, "/image hello world");
    }

    #[test]
    fn enter_on_bang_command_emits_inline_bash_send() {
        let mut s = AppState::default();
        s.editor.text = "!echo hi".into();
        s.editor.cursor_byte = s.editor.text.len();
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(
            cmds,
            vec![Command::RunInlineBash {
                command: "echo hi".into(),
                send_to_llm: true
            }]
        );
        assert!(s.editor.text.is_empty());
    }

    #[test]
    fn enter_on_double_bang_command_emits_inline_bash_no_send() {
        let mut s = AppState::default();
        s.editor.text = "!!ls -la".into();
        s.editor.cursor_byte = s.editor.text.len();
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(
            cmds,
            vec![Command::RunInlineBash {
                command: "ls -la".into(),
                send_to_llm: false
            }]
        );
        assert!(s.editor.text.is_empty());
    }

    #[test]
    fn bang_with_no_command_is_treated_as_normal_text() {
        let mut s = AppState::default();
        s.editor.text = "!".into();
        s.editor.cursor_byte = 1;
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        // "!" alone trims to empty command → fall through to normal send.
        assert_eq!(
            cmds,
            vec![Command::SendUserMessage {
                text: "!".into(),
                attachments: Vec::new(),
            }]
        );
    }

    #[test]
    fn esc_while_streaming_sends_cancel() {
        let mut s = AppState {
            in_flight: Some(InFlight::StreamingText {
                partial: "x".into(),
            }),
            ..Default::default()
        };
        let cmds = update(&mut s, key(KeyCode::Esc, KeyModifiers::NONE));
        assert_eq!(cmds, vec![Command::CancelAgent]);
    }

    #[test]
    fn enter_while_turn_in_flight_does_not_mutate_editor_or_send() {
        let mut s = AppState {
            in_flight: Some(InFlight::Thinking),
            ..Default::default()
        };
        s.editor.text = "second".into();
        s.editor.cursor_byte = s.editor.text.len();

        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));

        assert!(cmds.is_empty());
        assert_eq!(s.editor.text, "second");
        assert!(s.messages.is_empty());
    }

    #[test]
    fn ctrl_c_twice_within_two_seconds_quits() {
        let mut s = AppState::default();
        let cmds1 = update(&mut s, key(KeyCode::Char('c'), KeyModifiers::CONTROL));
        assert!(cmds1.is_empty(), "first Ctrl+C should clear editor only");
        let cmds2 = update(&mut s, key(KeyCode::Char('c'), KeyModifiers::CONTROL));
        assert_eq!(cmds2, vec![Command::Quit]);
    }

    #[test]
    fn ctrl_c_twice_with_palette_open_still_quits() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        assert!(matches!(s.bottom_pane, Some(BottomPane::Palette(_))));

        let cmds1 = update(&mut s, key(KeyCode::Char('c'), KeyModifiers::CONTROL));
        assert!(cmds1.is_empty(), "first Ctrl+C should close palette only");
        assert!(s.bottom_pane.is_none());
        let cmds2 = update(&mut s, key(KeyCode::Char('c'), KeyModifiers::CONTROL));
        assert_eq!(cmds2, vec![Command::Quit]);
    }

    #[test]
    fn slash_does_not_open_palette_during_active_turn() {
        let mut s = AppState {
            in_flight: Some(InFlight::Thinking),
            ..Default::default()
        };
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none());
        assert!(s.editor.text.is_empty());
    }

    #[test]
    fn typing_slash_into_empty_editor_opens_palette() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        assert!(matches!(s.bottom_pane, Some(BottomPane::Palette(_))));
        assert!(
            s.editor.text.is_empty(),
            "the / is consumed by the palette, not the editor"
        );
    }

    #[test]
    fn slash_mid_text_is_a_literal_char_not_a_palette() {
        let mut s = AppState::default();
        s.editor.text = "abc".into();
        s.editor.cursor_byte = 3;
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        assert!(
            s.bottom_pane.is_none(),
            "slash only opens the palette from an empty editor"
        );
        assert_eq!(s.editor.text, "abc/");
    }

    #[test]
    fn palette_typing_filters_and_backspace_unfilters() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Char('c'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Char('o'), KeyModifiers::NONE));
        match &s.bottom_pane {
            Some(BottomPane::Palette(p)) => assert_eq!(p.filter, "co"),
            other => panic!("{other:?}"),
        }
        update(&mut s, key(KeyCode::Backspace, KeyModifiers::NONE));
        match &s.bottom_pane {
            Some(BottomPane::Palette(p)) => assert_eq!(p.filter, "c"),
            other => panic!("{other:?}"),
        }
    }

    #[test]
    fn palette_backspace_on_empty_filter_closes_palette() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Backspace, KeyModifiers::NONE));
        assert!(
            s.bottom_pane.is_none(),
            "backspacing past the / closes the palette"
        );
    }

    #[test]
    fn palette_esc_closes_without_command() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        let cmds = update(&mut s, key(KeyCode::Esc, KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none());
        assert!(cmds.is_empty());
    }

    #[test]
    fn palette_enter_on_quit_sets_should_quit() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        // filter to "quit"
        for c in "quit".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(s.should_quit);
        assert_eq!(cmds, vec![Command::Quit]);
        assert!(s.bottom_pane.is_none());
    }

    #[test]
    fn palette_enter_on_compact_emits_compact_command() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "compact".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(cmds, vec![Command::Compact]);
        assert!(s.bottom_pane.is_none());
    }

    #[test]
    fn palette_enter_on_help_renders_help_block_no_command() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "help".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(cmds.is_empty());
        assert!(s.bottom_pane.is_none());
        assert!(
            matches!(s.messages.last(), Some(MessageBlock::Assistant { text, .. }) if text.contains("/compact")),
            "help should render a command list into the transcript"
        );
    }

    #[test]
    fn extension_list_populates_extension_commands() {
        let mut s = AppState::default();
        update(
            &mut s,
            Event::Ui(UiEvent::ExtensionList {
                extensions: vec![capo_agent::events::ExtensionInfo {
                    name: "todos".into(),
                    hooks: vec![],
                    commands: vec!["todo".into()],
                    healthy: true,
                    diagnostic: None,
                }],
            }),
        );

        assert_eq!(s.extension_commands, vec!["todo"]);
    }

    #[test]
    fn extension_list_omits_unhealthy_extension_commands_from_palette() {
        let mut s = AppState::default();
        update(
            &mut s,
            Event::Ui(UiEvent::ExtensionList {
                extensions: vec![
                    capo_agent::events::ExtensionInfo {
                        name: "broken".into(),
                        hooks: vec![],
                        commands: vec!["dead".into()],
                        healthy: false,
                        diagnostic: Some("binary not found".into()),
                    },
                    capo_agent::events::ExtensionInfo {
                        name: "working".into(),
                        hooks: vec![],
                        commands: vec!["live".into()],
                        healthy: true,
                        diagnostic: None,
                    },
                ],
            }),
        );

        assert_eq!(s.extension_commands, vec!["live"]);
    }

    #[test]
    fn palette_enter_on_extension_command_invokes_extension_with_empty_args() {
        let mut s = AppState {
            extension_commands: vec!["todo".into()],
            ..Default::default()
        };
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "todo".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }

        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));

        assert_eq!(
            cmds,
            vec![Command::InvokeExtensionCommand {
                name: "todo".into(),
                args: String::new(),
            }]
        );
        assert!(s.bottom_pane.is_none());
    }

    #[test]
    fn slash_new_emits_new_session_and_clears_transcript() {
        let mut s = AppState::default();
        s.messages.push(MessageBlock::User {
            text: "old".into(),
            attachment_filenames: Vec::new(),
        });
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "new".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(cmds, vec![Command::NewSession]);
        assert!(s.bottom_pane.is_none());
    }

    #[test]
    fn slash_clone_emits_clone_session_command() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "clone".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(cmds.iter().any(|c| matches!(c, Command::CloneSession)));
        assert!(s.bottom_pane.is_none(), "/clone opens no pane");
    }

    #[test]
    fn slash_model_opens_model_picker() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "model".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(matches!(s.bottom_pane, Some(BottomPane::ModelPicker(_))));
    }

    #[test]
    fn slash_resume_opens_resume_picker() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('/'), KeyModifiers::NONE));
        for c in "resume".chars() {
            update(&mut s, key(KeyCode::Char(c), KeyModifiers::NONE));
        }
        update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(matches!(s.bottom_pane, Some(BottomPane::ResumePicker(_))));
    }

    #[test]
    fn model_picker_enter_emits_switch_model() {
        let mut s = AppState {
            bottom_pane: Some(BottomPane::ModelPicker(crate::state::ModelPickerState {
                query: "opus".into(),
                selected: 0,
            })),
            ..Default::default()
        };
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(
            cmds,
            vec![Command::SwitchModel(capo_agent::ModelId::from(
                "claude-opus-4-7"
            ))]
        );
        assert!(s.bottom_pane.is_none());
    }

    #[test]
    fn model_picker_free_type_enter_emits_typed_model() {
        let mut s = AppState {
            bottom_pane: Some(BottomPane::ModelPicker(crate::state::ModelPickerState {
                query: "gpt-5".into(),
                selected: 0,
            })),
            ..Default::default()
        };
        // rows("gpt-5") == ["use \"gpt-5\""], selected 0 → the free-type row.
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(
            cmds,
            vec![Command::SwitchModel(capo_agent::ModelId::from("gpt-5"))]
        );
    }

    #[test]
    fn resume_picker_enter_emits_load_session() {
        let mut s = AppState {
            sessions: vec![crate::state::SessionSummary {
                id: "sess-7".into(),
                label: "sess-7  …".into(),
            }],
            bottom_pane: Some(BottomPane::ResumePicker(
                crate::state::ResumePickerState::default(),
            )),
            ..Default::default()
        };
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(cmds, vec![Command::LoadSession("sess-7".into())]);
        assert!(s.bottom_pane.is_none());
    }

    #[test]
    fn resume_picker_enter_with_no_sessions_just_closes() {
        let mut s = AppState {
            bottom_pane: Some(BottomPane::ResumePicker(
                crate::state::ResumePickerState::default(),
            )),
            ..Default::default()
        };
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(cmds.is_empty());
        assert!(s.bottom_pane.is_none());
    }

    #[test]
    fn resume_picker_selection_is_limited_to_visible_rows() {
        let mut s = AppState {
            sessions: (0..12)
                .map(|i| crate::state::SessionSummary {
                    id: format!("sess-{i}"),
                    label: format!("sess-{i}"),
                })
                .collect(),
            bottom_pane: Some(BottomPane::ResumePicker(
                crate::state::ResumePickerState::default(),
            )),
            ..Default::default()
        };
        for _ in 0..20 {
            update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        }
        match &s.bottom_pane {
            Some(BottomPane::ResumePicker(rp)) => {
                assert_eq!(rp.selected, crate::widgets::resume_picker::MAX_ROWS - 1)
            }
            other => panic!("{other:?}"),
        }
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(cmds, vec![Command::LoadSession("sess-9".into())]);
    }

    #[test]
    fn ctrl_e_toggles_expanded_all() {
        let mut s = AppState::default();
        assert!(!s.expanded_all, "default is collapsed");

        let cmds = update(
            &mut s,
            Event::Input(InputEvent::Key(crossterm::event::KeyEvent::new(
                crossterm::event::KeyCode::Char('e'),
                crossterm::event::KeyModifiers::CONTROL,
            ))),
        );
        assert!(cmds.is_empty());
        assert!(s.expanded_all, "Ctrl+E should set expanded_all = true");

        let cmds = update(
            &mut s,
            Event::Input(InputEvent::Key(crossterm::event::KeyEvent::new(
                crossterm::event::KeyCode::Char('e'),
                crossterm::event::KeyModifiers::CONTROL,
            ))),
        );
        assert!(cmds.is_empty());
        assert!(!s.expanded_all, "second Ctrl+E should toggle back to false");
    }

    #[test]
    fn typing_appends_to_editor() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('h'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Char('i'), KeyModifiers::NONE));
        assert_eq!(s.editor.text, "hi");
    }

    fn file_index_state() -> AppState {
        AppState {
            file_index: crate::file_index::FileIndex::from_paths(vec![
                "src/main.rs".into(),
                "README.md".into(),
            ]),
            ..Default::default()
        }
    }

    #[test]
    fn typing_at_opens_file_picker() {
        let mut s = file_index_state();
        update(&mut s, key(KeyCode::Char('@'), KeyModifiers::NONE));
        assert!(matches!(s.bottom_pane, Some(BottomPane::FilePicker(_))));
        assert_eq!(s.editor.text, "", "the @ is consumed by the picker");
    }

    #[test]
    fn file_picker_filters_as_you_type() {
        let mut s = file_index_state();
        update(&mut s, key(KeyCode::Char('@'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Char('m'), KeyModifiers::NONE));
        match &s.bottom_pane {
            Some(BottomPane::FilePicker(fp)) => {
                assert_eq!(fp.query, "m");
                assert!(fp.matches.iter().any(|p| p == "src/main.rs"));
            }
            other => panic!("{other:?}"),
        }
    }

    #[test]
    fn file_picker_enter_inserts_path_into_editor() {
        let mut s = file_index_state();
        update(&mut s, key(KeyCode::Char('@'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Char('m'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none());
        assert_eq!(s.editor.text, "src/main.rs ");
    }

    #[test]
    fn file_picker_esc_closes_without_insert() {
        let mut s = file_index_state();
        update(&mut s, key(KeyCode::Char('@'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Esc, KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none());
        assert_eq!(s.editor.text, "");
    }

    #[test]
    fn file_picker_at_preserves_existing_editor_text() {
        let mut s = file_index_state();
        s.editor.text = "see ".into();
        s.editor.cursor_byte = 4;
        update(&mut s, key(KeyCode::Char('@'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Char('m'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert_eq!(s.editor.text, "see src/main.rs ");
    }

    #[test]
    fn ctrl_c_with_file_picker_open_closes_and_preserves_draft() {
        let mut s = file_index_state();
        s.editor.text = "see ".into();
        s.editor.cursor_byte = 4;
        update(&mut s, key(KeyCode::Char('@'), KeyModifiers::NONE));
        assert!(matches!(s.bottom_pane, Some(BottomPane::FilePicker(_))));

        let cmds1 = update(&mut s, key(KeyCode::Char('c'), KeyModifiers::CONTROL));
        assert!(cmds1.is_empty(), "Ctrl+C should close picker only");
        assert!(s.bottom_pane.is_none());
        assert_eq!(s.editor.text, "see ");
        assert!(s.last_ctrl_c.is_none());
    }

    fn approval_state_open(tool: &str) -> ApprovalState {
        ApprovalState {
            question: format!("Allow capo to run this `{tool}` call?"),
            args_preview: "{}".into(),
            options: default_approval_options(),
            selected: 0,
            tool: tool.into(),
            args: serde_json::json!({}),
            resolver: None,
        }
    }

    #[test]
    fn typing_while_approval_open_does_not_reach_editor() {
        let mut s = AppState {
            bottom_pane: Some(BottomPane::Approval(approval_state_open("bash"))),
            ..Default::default()
        };
        // 'x' isn't a shortcut on any option, so the pane swallows it.
        update(&mut s, key(KeyCode::Char('x'), KeyModifiers::NONE));
        assert!(s.editor.text.is_empty(), "editor should not change");
        assert!(s.bottom_pane.is_some(), "approval pane should remain open");
    }

    #[test]
    fn paste_while_approval_open_does_not_reach_editor() {
        let mut s = AppState {
            bottom_pane: Some(BottomPane::Approval(approval_state_open("bash"))),
            ..Default::default()
        };
        update(&mut s, Event::Input(InputEvent::Paste("x".into())));
        assert!(s.editor.text.is_empty(), "editor should not change");
        assert!(s.bottom_pane.is_some(), "approval pane should remain open");
    }

    #[test]
    fn shift_enter_inserts_newline() {
        let mut s = AppState::default();
        update(&mut s, key(KeyCode::Char('a'), KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Enter, KeyModifiers::SHIFT));
        update(&mut s, key(KeyCode::Char('b'), KeyModifiers::NONE));
        assert_eq!(s.editor.text, "a\nb");
    }

    #[test]
    fn backspace_removes_last_char() {
        let mut s = AppState::default();
        s.editor.text = "abc".into();
        s.editor.cursor_byte = 3;
        update(&mut s, key(KeyCode::Backspace, KeyModifiers::NONE));
        assert_eq!(s.editor.text, "ab");
    }

    #[test]
    fn permission_requested_event_opens_approval_pane_with_three_options() {
        let mut s = AppState::default();
        let (tx, _rx) = tokio::sync::oneshot::channel::<capo_agent::Decision>();
        update(
            &mut s,
            capo_agent::UiEvent::PermissionRequested {
                tool: "bash".into(),
                args: serde_json::json!({ "command": "x" }),
                resolver: tx,
            }
            .into(),
        );
        match &s.bottom_pane {
            Some(BottomPane::Approval(a)) => {
                assert_eq!(a.tool, "bash");
                assert_eq!(a.args, serde_json::json!({"command": "x"}));
                assert!(a.resolver.is_some(), "resolver must be wired");
                assert_eq!(a.options.len(), 3, "three default options");
                assert_eq!(a.selected, 0, "starts on first option");
                assert!(a.question.contains("bash"));
            }
            other => panic!("expected Approval bottom pane, got: {other:?}"),
        }
    }

    #[test]
    fn approval_letter_shortcut_s_emits_session_allow_resolution() {
        let args = serde_json::json!({"command": "echo hi"});
        let (tx, mut rx) = tokio::sync::oneshot::channel::<capo_agent::Decision>();
        let mut a = approval_state_open("bash");
        a.args = args.clone();
        a.resolver = Some(tx);
        let mut s = AppState {
            bottom_pane: Some(BottomPane::Approval(a)),
            ..Default::default()
        };

        let cmds = update(&mut s, key(KeyCode::Char('S'), KeyModifiers::NONE));

        assert!(s.bottom_pane.is_none(), "pane closes on resolve");
        assert_eq!(rx.try_recv(), Ok(capo_agent::Decision::Allowed));
        assert_eq!(
            cmds,
            vec![Command::ResolvePermission(PermissionResolution {
                tool: "bash".into(),
                args,
                choice: PermissionChoice::AllowSession,
            })]
        );
    }

    #[test]
    fn approval_up_down_moves_selection_without_resolving() {
        let mut s = AppState {
            bottom_pane: Some(BottomPane::Approval(approval_state_open("write"))),
            ..Default::default()
        };
        // Down twice (0 → 1 → 2), then up (2 → 1).
        update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Down, KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        match &s.bottom_pane {
            Some(BottomPane::Approval(a)) => assert_eq!(a.selected, 1),
            other => panic!("pane closed unexpectedly: {other:?}"),
        }
        // Saturating: Up at 0 stays at 0; Down at last stays at last.
        update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        update(&mut s, key(KeyCode::Up, KeyModifiers::NONE));
        match &s.bottom_pane {
            Some(BottomPane::Approval(a)) => assert_eq!(a.selected, 0),
            other => panic!("pane closed unexpectedly: {other:?}"),
        }
    }

    #[test]
    fn approval_enter_resolves_currently_selected_option() {
        let (tx, mut rx) = tokio::sync::oneshot::channel::<capo_agent::Decision>();
        let mut a = approval_state_open("bash");
        a.resolver = Some(tx);
        a.selected = 1; // AllowSession
        let mut s = AppState {
            bottom_pane: Some(BottomPane::Approval(a)),
            ..Default::default()
        };
        let cmds = update(&mut s, key(KeyCode::Enter, KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none());
        assert_eq!(rx.try_recv(), Ok(capo_agent::Decision::Allowed));
        match &cmds[..] {
            [Command::ResolvePermission(r)] => {
                assert_eq!(r.choice, PermissionChoice::AllowSession);
            }
            other => panic!("expected single ResolvePermission, got: {other:?}"),
        }
    }

    #[test]
    fn approval_esc_always_denies_even_when_other_option_selected() {
        let (tx, mut rx) = tokio::sync::oneshot::channel::<capo_agent::Decision>();
        let mut a = approval_state_open("bash");
        a.resolver = Some(tx);
        a.selected = 0; // AllowOnce highlighted — but Esc must still Deny.
        let mut s = AppState {
            bottom_pane: Some(BottomPane::Approval(a)),
            ..Default::default()
        };
        let cmds = update(&mut s, key(KeyCode::Esc, KeyModifiers::NONE));
        assert!(s.bottom_pane.is_none());
        assert!(matches!(rx.try_recv(), Ok(capo_agent::Decision::Denied(_))));
        match &cmds[..] {
            [Command::ResolvePermission(r)] => {
                assert_eq!(r.choice, PermissionChoice::Deny);
            }
            other => panic!("expected single Deny ResolvePermission, got: {other:?}"),
        }
    }
}
