use super::components::{ButtonPressedTarget, PressedButton};
use super::*;
use chrono::Local;
const MOUSE_WHEEL_LINES: usize = 3;
const MIN_LEFT_WIDTH_PCT: u16 = 14;
const MAX_LEFT_WIDTH_PCT: u16 = 38;
const MIN_RIGHT_WIDTH_PCT: u16 = 14;
const MAX_RIGHT_WIDTH_PCT: u16 = 50;
const MIN_CENTER_WIDTH_PCT: u16 = 20;
const DOUBLE_CLICK_THRESHOLD: Duration = Duration::from_millis(500);
const ESC_AMBIGUITY_TIMEOUT: Duration = Duration::from_millis(25);

fn configure_project_text_input_mut(prompt: &mut PromptState) -> Option<&mut TextInput> {
    match prompt {
        PromptState::ConfigureStartupCommand { input, .. }
        | PromptState::ConfigureProjectEnv { input, .. }
        | PromptState::ConfigureGlobalEnv { input, .. } => Some(input),
        _ => None,
    }
}

/// Maximum size of `loading_input_buf`. During the loading phase we
/// accumulate bytes only to detect a (possibly multi-byte) ExitInteractive
/// binding. Since any realistic binding fits in well under this cap, we
/// truncate to the trailing window to bound memory and scan cost even when
/// the user pastes large amounts of text while the agent is starting.
const LOADING_INPUT_BUF_MAX: usize = 64;

/// Build the byte payload for a macro send. Newlines are translated to
/// Alt+Enter (ESC followed by CR) so that multi-line macros are entered
/// as a single multi-line prompt rather than submitting at each newline.
/// Handles `\r\n`, `\n`, and bare `\r` uniformly.
pub(crate) fn macro_payload_bytes(text: &str) -> Vec<u8> {
    const ALT_ENTER: &[u8] = b"\x1b\r";
    let bytes = text.as_bytes();
    let mut payload = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        match bytes[i] {
            b'\r' if bytes.get(i + 1) == Some(&b'\n') => {
                payload.extend_from_slice(ALT_ENTER);
                i += 2;
            }
            b'\n' | b'\r' => {
                payload.extend_from_slice(ALT_ENTER);
                i += 1;
            }
            b => {
                payload.push(b);
                i += 1;
            }
        }
    }
    payload
}

/// Append `bytes` to `buf`, keeping at most `max` trailing bytes. Used to
/// bound `loading_input_buf` so it cannot grow without bound when the user
/// pastes large content during the agent loading phase.
fn append_capped(buf: &mut Vec<u8>, bytes: &[u8], max: usize) {
    if bytes.len() >= max {
        // The new chunk alone fills or exceeds the cap: drop prior state
        // and keep only the trailing `max` bytes of the new chunk.
        buf.clear();
        buf.extend_from_slice(&bytes[bytes.len() - max..]);
        return;
    }
    buf.extend_from_slice(bytes);
    if buf.len() > max {
        let drop = buf.len() - max;
        buf.drain(..drop);
    }
}

/// Treat both crossterm's `BackTab` and explicit `Tab + Shift` as reverse-tab.
/// Some terminals deliver one form, some the other.
fn is_reverse_tab(key: KeyEvent) -> bool {
    matches!(key.code, KeyCode::BackTab)
        || (matches!(key.code, KeyCode::Tab) && key.modifiers.contains(KeyModifiers::SHIFT))
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MouseTarget {
    LeftPane,
    LeftRow(usize),
    TerminalRow(usize),
    TerminalPane,
    Center,
    FilesPane,
    UnstagedFile(Option<usize>),
    StagedFile(Option<usize>),
    CommitChrome,
    CommitText,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PromptMouseTarget {
    CommandInput,
    CommandItem(usize),
    BrowseProjectInput,
    BrowseProjectItem(usize),
    PickEditorItem(usize),
    PickProjectWorktreeItem(usize),
    ChangeThemeItem(usize),
    ChangeAgentProviderItem(usize),
    ChangeAgentProviderCancel,
    ChangeAgentProviderApply,
    ChangeDefaultProviderItem(usize),
    ChangeDefaultProviderCancel,
    ChangeDefaultProviderApply,
    ChangeProjectDefaultProviderItem(usize),
    ChangeProjectDefaultProviderCancel,
    ChangeProjectDefaultProviderApply,
    RuntimeKillInput,
    RuntimeKillItem(usize),
    RuntimeKillCancel,
    RuntimeKillHovered,
    RuntimeKillSelected,
    RuntimeKillVisible,
    ConfirmKillCancel,
    ConfirmKillConfirm,
    ConfirmDeleteCancel,
    ConfirmDeleteConfirm,
    ConfirmDeleteTerminalCancel,
    ConfirmDeleteTerminalConfirm,
    ConfirmDeleteMacroCancel,
    ConfirmDeleteMacroConfirm,
    ConfirmQuitCancel,
    ConfirmQuitConfirm,
    ConfirmDiscardCancel,
    ConfirmDiscardConfirm,
    ConfirmNonDefaultBranchCancel,
    ConfirmNonDefaultBranchAdd,
    ConfirmUseExistingBranchCancel,
    ConfirmUseExistingBranchUse,
    ConfigReloadFailedClose,
    ConfigReloadFailedApply,
    AddProjectFailedOk,
    StartupCommandLogItem(usize),
    StartupCommandLogsClose,
    StartupCommandInput,
    Checkbox(OverlayCheckboxId),
    RenameInput,
    NameNewAgentInput,
}

impl ButtonPressedTarget {
    /// Map a hit-tested prompt target to its button-press identifier.
    /// Returns `None` for non-button targets (text inputs, list rows,
    /// checkboxes) so the press machinery cannot accidentally arm them —
    /// those targets keep their existing on-Down behavior.
    fn from_prompt_target(target: PromptMouseTarget) -> Option<Self> {
        match target {
            PromptMouseTarget::ChangeAgentProviderCancel => {
                Some(ButtonPressedTarget::ChangeAgentProviderCancel)
            }
            PromptMouseTarget::ChangeAgentProviderApply => {
                Some(ButtonPressedTarget::ChangeAgentProviderApply)
            }
            PromptMouseTarget::ChangeDefaultProviderCancel => {
                Some(ButtonPressedTarget::ChangeDefaultProviderCancel)
            }
            PromptMouseTarget::ChangeDefaultProviderApply => {
                Some(ButtonPressedTarget::ChangeDefaultProviderApply)
            }
            PromptMouseTarget::ChangeProjectDefaultProviderCancel => {
                Some(ButtonPressedTarget::ChangeProjectDefaultProviderCancel)
            }
            PromptMouseTarget::ChangeProjectDefaultProviderApply => {
                Some(ButtonPressedTarget::ChangeProjectDefaultProviderApply)
            }
            PromptMouseTarget::RuntimeKillCancel => Some(ButtonPressedTarget::RuntimeKillCancel),
            PromptMouseTarget::RuntimeKillHovered => Some(ButtonPressedTarget::RuntimeKillHovered),
            PromptMouseTarget::RuntimeKillSelected => {
                Some(ButtonPressedTarget::RuntimeKillSelected)
            }
            PromptMouseTarget::RuntimeKillVisible => Some(ButtonPressedTarget::RuntimeKillVisible),
            PromptMouseTarget::ConfirmKillCancel => Some(ButtonPressedTarget::ConfirmKillCancel),
            PromptMouseTarget::ConfirmKillConfirm => Some(ButtonPressedTarget::ConfirmKillConfirm),
            PromptMouseTarget::ConfirmDeleteCancel => {
                Some(ButtonPressedTarget::ConfirmDeleteCancel)
            }
            PromptMouseTarget::ConfirmDeleteConfirm => {
                Some(ButtonPressedTarget::ConfirmDeleteConfirm)
            }
            PromptMouseTarget::ConfirmDeleteTerminalCancel => {
                Some(ButtonPressedTarget::ConfirmDeleteTerminalCancel)
            }
            PromptMouseTarget::ConfirmDeleteTerminalConfirm => {
                Some(ButtonPressedTarget::ConfirmDeleteTerminalConfirm)
            }
            PromptMouseTarget::ConfirmDeleteMacroCancel => {
                Some(ButtonPressedTarget::ConfirmDeleteMacroCancel)
            }
            PromptMouseTarget::ConfirmDeleteMacroConfirm => {
                Some(ButtonPressedTarget::ConfirmDeleteMacroConfirm)
            }
            PromptMouseTarget::ConfirmQuitCancel => Some(ButtonPressedTarget::ConfirmQuitCancel),
            PromptMouseTarget::ConfirmQuitConfirm => Some(ButtonPressedTarget::ConfirmQuitConfirm),
            PromptMouseTarget::ConfirmDiscardCancel => {
                Some(ButtonPressedTarget::ConfirmDiscardCancel)
            }
            PromptMouseTarget::ConfirmDiscardConfirm => {
                Some(ButtonPressedTarget::ConfirmDiscardConfirm)
            }
            PromptMouseTarget::ConfirmNonDefaultBranchCancel => {
                Some(ButtonPressedTarget::ConfirmNonDefaultBranchCancel)
            }
            PromptMouseTarget::ConfirmNonDefaultBranchAdd => {
                Some(ButtonPressedTarget::ConfirmNonDefaultBranchAdd)
            }
            PromptMouseTarget::ConfirmUseExistingBranchCancel => {
                Some(ButtonPressedTarget::ConfirmUseExistingBranchCancel)
            }
            PromptMouseTarget::ConfirmUseExistingBranchUse => {
                Some(ButtonPressedTarget::ConfirmUseExistingBranchUse)
            }
            PromptMouseTarget::ConfigReloadFailedClose => {
                Some(ButtonPressedTarget::ConfigReloadFailedClose)
            }
            PromptMouseTarget::ConfigReloadFailedApply => {
                Some(ButtonPressedTarget::ConfigReloadFailedApply)
            }
            PromptMouseTarget::AddProjectFailedOk => Some(ButtonPressedTarget::AddProjectFailedOk),
            PromptMouseTarget::StartupCommandLogsClose => {
                Some(ButtonPressedTarget::StartupCommandLogsClose)
            }
            PromptMouseTarget::CommandInput
            | PromptMouseTarget::CommandItem(_)
            | PromptMouseTarget::BrowseProjectInput
            | PromptMouseTarget::BrowseProjectItem(_)
            | PromptMouseTarget::PickEditorItem(_)
            | PromptMouseTarget::PickProjectWorktreeItem(_)
            | PromptMouseTarget::StartupCommandLogItem(_)
            | PromptMouseTarget::StartupCommandInput
            | PromptMouseTarget::ChangeThemeItem(_)
            | PromptMouseTarget::ChangeAgentProviderItem(_)
            | PromptMouseTarget::ChangeDefaultProviderItem(_)
            | PromptMouseTarget::ChangeProjectDefaultProviderItem(_)
            | PromptMouseTarget::RuntimeKillInput
            | PromptMouseTarget::RuntimeKillItem(_)
            | PromptMouseTarget::Checkbox(_)
            | PromptMouseTarget::RenameInput
            | PromptMouseTarget::NameNewAgentInput => None,
        }
    }
}

fn contains_point(rect: Rect, column: u16, row: u16) -> bool {
    rect.width > 0
        && rect.height > 0
        && column >= rect.x
        && column < rect.x.saturating_add(rect.width)
        && row >= rect.y
        && row < rect.y.saturating_add(rect.height)
}

fn relative_point(rect: Rect, column: u16, row: u16) -> Option<(u16, u16)> {
    contains_point(rect, column, row)
        .then_some((column.saturating_sub(rect.x), row.saturating_sub(rect.y)))
}

fn relative_point_clamped(rect: Rect, column: u16, row: u16) -> (u16, u16) {
    let column = column
        .max(rect.x)
        .min(rect.x.saturating_add(rect.width.saturating_sub(1)));
    let row = row
        .max(rect.y)
        .min(rect.y.saturating_add(rect.height.saturating_sub(1)));
    (column.saturating_sub(rect.x), row.saturating_sub(rect.y))
}

fn cursor_from_single_line_position(
    text: &str,
    text_area: Rect,
    prefix_width: usize,
    column: u16,
) -> usize {
    let relative_col = usize::from(column.saturating_sub(text_area.x));
    let target_col = relative_col
        .saturating_sub(prefix_width)
        .min(text.chars().count());
    text.char_indices()
        .nth(target_col)
        .map(|(idx, _)| idx)
        .unwrap_or(text.len())
}

fn clamp_left_width_pct(left_width_pct: u16, right_width_pct: u16) -> u16 {
    let max_left = MAX_LEFT_WIDTH_PCT.min(100 - MIN_CENTER_WIDTH_PCT - right_width_pct);
    left_width_pct.clamp(MIN_LEFT_WIDTH_PCT, max_left.max(MIN_LEFT_WIDTH_PCT))
}

fn clamp_right_width_pct(right_width_pct: u16, left_width_pct: u16) -> u16 {
    let max_right = MAX_RIGHT_WIDTH_PCT.min(100 - MIN_CENTER_WIDTH_PCT - left_width_pct);
    right_width_pct.clamp(MIN_RIGHT_WIDTH_PCT, max_right.max(MIN_RIGHT_WIDTH_PCT))
}

const MIN_TERMINAL_PANE_HEIGHT_PCT: u16 = 10;
const MAX_TERMINAL_PANE_HEIGHT_PCT: u16 = 80;
const MIN_STAGED_PANE_HEIGHT_PCT: u16 = 10;
const MAX_STAGED_PANE_HEIGHT_PCT: u16 = 80;
const MIN_COMMIT_PANE_HEIGHT_PCT: u16 = 10;
const MAX_COMMIT_PANE_HEIGHT_PCT: u16 = 80;

fn pct_from_columns(columns: u16, total_width: u16) -> u16 {
    if total_width == 0 {
        return 0;
    }

    (((u32::from(columns) * 100) + (u32::from(total_width) / 2)) / u32::from(total_width)) as u16
}

pub(crate) fn startup_command_log_visual_lines(content: &str, width: u16) -> Vec<String> {
    if width == 0 {
        return Vec::new();
    }
    let width = usize::from(width);
    let mut lines = Vec::new();
    for line in content.split('\n') {
        let chars = line.chars().collect::<Vec<_>>();
        if chars.is_empty() {
            lines.push(String::new());
            continue;
        }
        for chunk in chars.chunks(width) {
            lines.push(chunk.iter().collect());
        }
    }
    lines
}

fn startup_command_log_max_scroll(content: &str, body: Option<Rect>) -> u16 {
    let Some(body) = body else {
        return u16::MAX;
    };
    u16::try_from(startup_command_log_visual_lines(content, body.width).len())
        .unwrap_or(u16::MAX)
        .saturating_sub(body.height)
}

fn scroll_startup_command_log(
    prompt: &mut StartupCommandLogPrompt,
    body: Option<Rect>,
    delta: i16,
) {
    if delta >= 0 {
        prompt.scroll_offset = prompt
            .scroll_offset
            .saturating_add(delta as u16)
            .min(startup_command_log_max_scroll(&prompt.content, body));
    } else {
        prompt.scroll_offset = prompt.scroll_offset.saturating_sub(delta.unsigned_abs());
    }
}

impl App {
    pub(crate) fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
        // Prompts take precedence over every other input target so modal text
        // fields can safely capture keystrokes even when other modes were
        // previously active.
        if !matches!(self.prompt, PromptState::None) {
            return self.handle_prompt_key(key);
        }
        if matches!(self.fullscreen_overlay, FullscreenOverlay::StartupLog) {
            self.handle_startup_log_viewer_key(key);
            return Ok(false);
        }
        // Macro bar consumes all keys when open.
        if self.macro_bar.is_some() {
            return self.handle_macro_bar_key(key);
        }
        // Interactive mode is handled at the event-loop level via raw stdin
        // passthrough (poll_and_forward_raw_input). When the input target is
        // Agent or Terminal, crossterm's event reader is not called, so
        // handle_key is never reached for those modes.
        debug_assert!(
            !matches!(
                self.input_target,
                InputTarget::Agent | InputTarget::Terminal
            ),
            "handle_key should not be called in interactive mode"
        );
        if self.bindings.lookup(&key, BindingScope::Global) == Some(Action::CloseOverlay)
            && self.close_top_overlay()
        {
            return Ok(false);
        }
        if key.code == KeyCode::Esc
            && self.focus == FocusPane::Files
            && (self.files_search_active || self.has_files_search())
        {
            self.handle_files_key(key)?;
            return Ok(false);
        }
        if let Some(ref mut scroll) = self.help_scroll {
            // Help overlay is open — consume all keys, only scroll keys do anything.
            let max_help = self
                .last_help_lines
                .saturating_sub(self.last_help_height.max(1));
            if let Some(action) = self.bindings.lookup(&key, BindingScope::Help) {
                match action {
                    Action::MoveDown => *scroll = (*scroll + 1).min(max_help),
                    Action::MoveUp => *scroll = scroll.saturating_sub(1),
                    Action::ScrollPageDown => {
                        let page = self.last_help_height.max(1);
                        *scroll = (*scroll + page).min(max_help);
                    }
                    Action::ScrollPageUp => {
                        let page = self.last_help_height.max(1);
                        *scroll = scroll.saturating_sub(page);
                    }
                    Action::ScrollToBottom => *scroll = max_help,
                    Action::ScrollToTop => *scroll = 0,
                    _ => {}
                }
            } else if key.code == KeyCode::Char(' ') {
                // Space scrolls down one line in the help overlay (not bound
                // via MoveDown to avoid conflicting with ToggleProject/StageUnstage
                // in other scopes).
                *scroll = (*scroll + 1).min(max_help);
            }
            return Ok(false);
        }
        // When typing a commit message, route all keys to the commit input
        // handler so that q, ?, [ etc. are typed instead of triggering
        // global shortcuts.
        if self.input_target == InputTarget::CommitMessage {
            self.handle_commit_input_key(key)?;
            return Ok(false);
        }
        if self.input_target == InputTarget::StartupCommand {
            self.handle_startup_command_input_key(key)?;
            return Ok(false);
        }
        // Check if a Global binding should defer to a pane-scoped binding.
        // For example, `q` is Quit globally but ScrollToBottom in Center — if
        // the focused pane has a binding for this key, skip the global handler
        // and let the pane handler run instead.
        let defer_global = if self.bindings.lookup(&key, BindingScope::Global) == Some(Action::Quit)
        {
            let pane_scope = match self.focus {
                FocusPane::Center => Some(BindingScope::Center),
                FocusPane::Files => Some(BindingScope::Files),
                _ => None,
            };
            pane_scope.is_some_and(|scope| self.bindings.lookup(&key, scope).is_some())
        } else {
            false
        };

        if !defer_global && let Some(action) = self.bindings.lookup(&key, BindingScope::Global) {
            match action {
                Action::Quit => {
                    let agent_count = self.providers.len();
                    let terminal_count = self.running_companion_terminal_count();
                    if agent_count + terminal_count > 0 {
                        self.prompt = PromptState::ConfirmQuit {
                            agent_count,
                            terminal_count,
                            confirm_selected: false,
                        };
                        return Ok(false);
                    }
                    return Ok(true);
                }
                Action::ToggleHelp => {
                    self.help_scroll = if self.help_scroll.is_some() {
                        None
                    } else {
                        Some(0)
                    };
                }
                Action::ForceRedraw => {
                    self.force_redraw = true;
                    self.set_info("Interface redrawn. All screen contents have been repainted.");
                }
                Action::OpenPalette => {
                    self.prompt = PromptState::Command {
                        input: TextInput::new(),
                        selected: 0,
                    };
                    self.set_info("Command palette opened.");
                }
                Action::FocusNext => {
                    let has_staged = !self.staged_files.is_empty();
                    if self.focus == FocusPane::Left
                        && self.left_section == LeftSection::Projects
                        && self.has_terminal_items()
                    {
                        self.left_section = LeftSection::Terminals;
                        self.clamp_terminal_cursor();
                    } else if self.focus == FocusPane::Left
                        && self.left_section == LeftSection::Terminals
                    {
                        self.left_section = LeftSection::Projects;
                        self.focus = self.focus.next();
                    } else if self.focus == FocusPane::Files {
                        match self.right_section.next(has_staged) {
                            Some(next) => {
                                self.right_section = next;
                                self.clamp_files_cursor();
                            }
                            None => {
                                self.focus = self.focus.next();
                                self.left_section = LeftSection::Projects;
                            }
                        }
                    } else {
                        self.focus = self.focus.next();
                        if self.focus == FocusPane::Files && self.right_hidden {
                            self.focus = self.focus.next();
                        }
                        if self.focus == FocusPane::Files {
                            self.right_section = RightSection::first();
                            self.clamp_files_cursor();
                        } else if self.focus == FocusPane::Left {
                            self.left_section = LeftSection::Projects;
                        }
                    }
                    self.input_target = InputTarget::None;
                    self.fullscreen_overlay = FullscreenOverlay::None;
                }
                Action::FocusPrev => {
                    let has_staged = !self.staged_files.is_empty();
                    if self.focus == FocusPane::Left && self.left_section == LeftSection::Terminals
                    {
                        self.left_section = LeftSection::Projects;
                    } else if self.focus == FocusPane::Left
                        && self.left_section == LeftSection::Projects
                    {
                        self.focus = self.focus.previous();
                        if self.focus == FocusPane::Files && self.right_hidden {
                            self.focus = self.focus.previous();
                        }
                        if self.focus == FocusPane::Files {
                            self.right_section = RightSection::last(has_staged);
                            self.clamp_files_cursor();
                        }
                    } else if self.focus == FocusPane::Files {
                        match self.right_section.previous() {
                            Some(prev) => {
                                self.right_section = prev;
                                self.clamp_files_cursor();
                            }
                            None => {
                                self.focus = self.focus.previous();
                            }
                        }
                    } else {
                        self.focus = self.focus.previous();
                        if self.focus == FocusPane::Files && self.right_hidden {
                            self.focus = self.focus.previous();
                        }
                        if self.focus == FocusPane::Files {
                            self.right_section = RightSection::last(has_staged);
                            self.clamp_files_cursor();
                        } else if self.focus == FocusPane::Left {
                            if self.has_terminal_items() {
                                self.left_section = LeftSection::Terminals;
                                self.clamp_terminal_cursor();
                            } else {
                                self.left_section = LeftSection::Projects;
                            }
                        }
                    }
                    self.input_target = InputTarget::None;
                    self.fullscreen_overlay = FullscreenOverlay::None;
                }
                Action::ToggleSidebar => {
                    self.left_collapsed = !self.left_collapsed;
                }
                Action::ToggleGitPane => {
                    self.right_collapsed = !self.right_collapsed;
                    if self.right_collapsed && self.focus == FocusPane::Files {
                        self.focus = FocusPane::Center;
                    }
                }
                Action::RemoveGitPane => {
                    self.right_hidden = !self.right_hidden;
                    if self.right_hidden && self.focus == FocusPane::Files {
                        self.focus = FocusPane::Center;
                    }
                }
                Action::ToggleResizeMode => {
                    self.resize_mode = !self.resize_mode;
                    if self.resize_mode {
                        let grow = self.bindings.labels_for(Action::ResizeGrow);
                        let shrink = self.bindings.labels_for(Action::ResizeShrink);
                        self.set_info(format!(
                            "Resize mode on: {shrink}/{grow} resize side panes."
                        ));
                    } else {
                        self.persist_pane_widths();
                        let key = self.bindings.label_for(Action::ToggleResizeMode);
                        self.set_info(format!(
                            "Resize mode off. Pane widths saved. Press {key} to re-enter."
                        ));
                    }
                }
                _ => {}
            }
            return Ok(false);
        } // !defer_global
        if self.resize_mode {
            self.handle_resize_key(key);
            return Ok(false);
        }

        match self.focus {
            FocusPane::Left => self.handle_left_key(key)?,
            FocusPane::Center => self.handle_center_key(key)?,
            FocusPane::Files => self.handle_files_key(key)?,
        }
        Ok(false)
    }

    fn handle_left_key(&mut self, key: KeyEvent) -> Result<()> {
        if self.left_section == LeftSection::Terminals {
            return self.handle_left_terminal_key(key);
        }

        if let Some(action) = self.bindings.lookup(&key, BindingScope::Left) {
            match action {
                Action::MoveDown => {
                    if let Some(next) = self.next_selectable_left_item_after(self.selected_left) {
                        self.selected_left = next;
                        self.close_diff_view();
                        self.reload_changed_files();
                        self.update_missing_project_warning();
                    } else if self.has_terminal_items() {
                        // Jump to terminals section.
                        self.left_section = LeftSection::Terminals;
                        self.selected_terminal_index = 0;
                        self.close_diff_view();
                    }
                }
                Action::MoveUp => {
                    if let Some(prev) =
                        self.previous_selectable_left_item_before(self.selected_left)
                    {
                        self.selected_left = prev;
                        self.close_diff_view();
                        self.reload_changed_files();
                        self.update_missing_project_warning();
                    }
                }
                Action::FocusAgent | Action::ExitInteractive => {
                    self.activate_selected_left_item()?
                }
                Action::OpenProjectBrowser => {
                    self.open_project_browser()?;
                }
                Action::NewAgent => self.create_agent_for_selected_project()?,
                Action::NewAgentFromWorktree => self.create_agent_from_existing_worktree()?,
                Action::ForkAgent => self.fork_selected_session()?,
                Action::RefreshProject => self.refresh_selected_project()?,
                Action::CheckoutProjectDefaultBranch => {
                    self.checkout_selected_project_default_branch()?
                }
                Action::ShowTerminal => self.show_or_open_first_terminal()?,
                Action::DeleteSession => self.confirm_delete_selected_session()?,
                Action::RenameSession => self.open_rename_session()?,
                Action::EditMacros => self.open_edit_macros(),
                Action::CopyPath => self.copy_selected_path()?,
                Action::OpenWorktreeInEditor => self.open_selected_worktree_in_default_editor()?,
                Action::ChooseWorktreeEditor => self.open_worktree_editor_picker()?,
                Action::ToggleProject => self.toggle_collapse_selected_project(),
                Action::InteractAgent => {
                    if self.selected_session().is_some()
                        && self
                            .selected_session()
                            .map(|s| self.providers.contains_key(&s.id))
                            .unwrap_or(false)
                    {
                        self.focus = FocusPane::Center;
                        self.center_mode = CenterMode::Agent;
                        self.input_target = InputTarget::Agent;
                        self.fullscreen_overlay = FullscreenOverlay::Agent;
                        let exit_key = self.bindings.label_for(Action::ExitInteractive);
                        self.set_info(format!(
                            "Interactive mode. Keys forwarded to agent. {exit_key} exits."
                        ));
                    } else {
                        let r = self.bindings.label_for(Action::ReconnectAgent);
                        let n = self.bindings.label_for(Action::NewAgent);
                        self.set_error(
                            format!("No active agent. Press \"{r}\" to restart or \"{n}\" to create a new one."),
                        );
                    }
                }
                _ => {}
            }
        }
        Ok(())
    }

    fn handle_left_terminal_key(&mut self, key: KeyEvent) -> Result<()> {
        let term_count = self.terminal_items().len();
        if let Some(action) = self.bindings.lookup(&key, BindingScope::Left) {
            match action {
                Action::MoveDown if self.selected_terminal_index + 1 < term_count => {
                    self.selected_terminal_index += 1;
                }
                Action::MoveUp => {
                    if self.selected_terminal_index > 0 {
                        self.selected_terminal_index -= 1;
                    } else {
                        // Jump back to projects section.
                        self.left_section = LeftSection::Projects;
                        if let Some(last) = self
                            .left_items()
                            .iter()
                            .enumerate()
                            .rev()
                            .find_map(|(idx, item)| item.is_selectable().then_some(idx))
                        {
                            self.selected_left = last;
                            self.close_diff_view();
                        }
                    }
                }
                Action::FocusAgent | Action::ExitInteractive => {
                    // Open terminal overlay for the selected terminal item.
                    self.open_terminal_from_terminal_list()?;
                }
                Action::ShowTerminal => {
                    self.spawn_terminal_for_selected_terminal()?;
                }
                Action::DeleteSession => self.confirm_delete_selected_terminal()?,
                _ => {}
            }
        }
        Ok(())
    }

    fn handle_center_key(&mut self, key: KeyEvent) -> Result<()> {
        let in_diff = matches!(self.center_mode, CenterMode::Diff { .. });
        if let Some(action) = self.bindings.lookup(&key, BindingScope::Center) {
            match action {
                Action::FocusAgent if !in_diff => self.activate_center_agent()?,
                Action::ExitInteractive if !in_diff => self.activate_center_agent()?,
                Action::ShowTerminal if !in_diff => self.show_or_open_first_terminal()?,
                Action::DeleteSession if !in_diff => self.confirm_delete_selected_session()?,
                Action::RenameSession if !in_diff => self.open_rename_session()?,
                Action::OpenCurrentPullRequest if !in_diff && self.current_pr_info().is_some() => {
                    self.open_current_pr_in_browser()?
                }
                Action::ReconnectAgent if !in_diff => {
                    // Allow relaunching an exited agent from the center pane,
                    // or entering interactive mode if the agent is active.
                    let has_provider = self
                        .selected_session()
                        .map(|s| self.providers.contains_key(&s.id))
                        .unwrap_or(false);
                    if has_provider {
                        self.reset_pty_scrollback();
                        self.input_target = InputTarget::Agent;
                        self.fullscreen_overlay = FullscreenOverlay::Agent;
                    } else if self.selected_session().is_some() {
                        self.reconnect_selected_session()?;
                    }
                }
                Action::ScrollPageUp => {
                    if let CenterMode::Diff { ref mut scroll, .. } = self.center_mode {
                        let page = self.last_diff_height.max(1);
                        *scroll = scroll.saturating_sub(page);
                    } else if self.last_pty_size.0 > 0 {
                        self.scroll_pty(ScrollDirection::Up, self.last_pty_size.0 as usize);
                    }
                }
                Action::ScrollPageDown => {
                    if let CenterMode::Diff { ref mut scroll, .. } = self.center_mode {
                        let page = self.last_diff_height.max(1);
                        let max_scroll = self
                            .last_diff_visual_lines
                            .saturating_sub(self.last_diff_height.max(1));
                        *scroll = (*scroll + page).min(max_scroll);
                    } else if self.last_pty_size.0 > 0 {
                        self.scroll_pty(ScrollDirection::Down, self.last_pty_size.0 as usize);
                    }
                }
                Action::ScrollLineUp => {
                    if let CenterMode::Diff { ref mut scroll, .. } = self.center_mode {
                        *scroll = scroll.saturating_sub(1);
                    } else if self.last_pty_size.0 > 0 {
                        self.scroll_pty(ScrollDirection::Up, 1);
                    }
                }
                Action::ScrollLineDown => {
                    if let CenterMode::Diff { ref mut scroll, .. } = self.center_mode {
                        let max_scroll = self
                            .last_diff_visual_lines
                            .saturating_sub(self.last_diff_height.max(1));
                        *scroll = (*scroll + 1).min(max_scroll);
                    } else if self.last_pty_size.0 > 0 {
                        self.scroll_pty(ScrollDirection::Down, 1);
                    }
                }
                Action::ScrollToBottom => {
                    if let CenterMode::Diff { ref mut scroll, .. } = self.center_mode {
                        let max_scroll = self
                            .last_diff_visual_lines
                            .saturating_sub(self.last_diff_height.max(1));
                        *scroll = max_scroll;
                    } else {
                        self.reset_pty_scrollback();
                    }
                }
                Action::ScrollToTop => {
                    if let CenterMode::Diff { ref mut scroll, .. } = self.center_mode {
                        *scroll = 0;
                    } else {
                        self.set_pty_scrollback_max();
                    }
                }
                _ => {}
            }
        } else if !in_diff && self.input_target == InputTarget::None {
            let is_typeable = matches!(
                key.code,
                KeyCode::Char(_) | KeyCode::Enter | KeyCode::Backspace
            );
            if is_typeable && key.modifiers.difference(KeyModifiers::SHIFT).is_empty() {
                self.readonly_nudge_tick = Some(self.tick_count);
            }
        }
        Ok(())
    }

    fn scroll_pty(&mut self, direction: ScrollDirection, amount: usize) {
        let provider = match self.selected_terminal_surface_client() {
            Some(provider) => provider,
            None => return,
        };
        let up = matches!(direction, ScrollDirection::Up);
        provider.scroll(up, amount);
    }

    fn reset_pty_scrollback(&self) {
        if let Some(provider) = self.selected_terminal_surface_client() {
            provider.set_scrollback(0);
        }
    }

    fn set_pty_scrollback_max(&self) {
        if let Some(provider) = self.selected_terminal_surface_client() {
            provider.set_scrollback(usize::MAX);
        }
    }

    fn startup_log_viewer_max_scroll(&self) -> u16 {
        let Some(viewer) = &self.startup_log_viewer else {
            return 0;
        };
        let Some(area) = self.mouse_layout.agent_term else {
            return 0;
        };
        u16::try_from(startup_command_log_visual_lines(&viewer.content, area.width).len())
            .unwrap_or(u16::MAX)
            .saturating_sub(area.height)
    }

    fn scroll_startup_log_viewer(&mut self, delta: i16) {
        let max_scroll = self.startup_log_viewer_max_scroll();
        let Some(viewer) = &mut self.startup_log_viewer else {
            return;
        };
        if delta >= 0 {
            viewer.scroll_offset = viewer
                .scroll_offset
                .saturating_add(delta as u16)
                .min(max_scroll);
        } else {
            viewer.scroll_offset = viewer.scroll_offset.saturating_sub(delta.unsigned_abs());
        }
    }

    fn update_startup_log_search_scroll(&mut self) {
        let Some(viewer) = &mut self.startup_log_viewer else {
            return;
        };
        let query = viewer.search.text.trim().to_lowercase();
        if query.is_empty() {
            viewer.scroll_offset = 0;
            return;
        }
        let Some(width) = self.mouse_layout.agent_term.map(|area| area.width) else {
            return;
        };
        if let Some(index) = startup_command_log_visual_lines(&viewer.content, width)
            .iter()
            .position(|line| line.to_lowercase().contains(&query))
        {
            viewer.scroll_offset = u16::try_from(index).unwrap_or(u16::MAX);
        } else {
            self.set_info("No startup command log search matches.");
        }
    }

    fn handle_startup_log_viewer_key(&mut self, key: KeyEvent) {
        let startup_action = self.bindings.lookup(&key, BindingScope::StartupCommandLogs);
        let center_action = self.bindings.lookup(&key, BindingScope::Center);
        let global_action = self.bindings.lookup(&key, BindingScope::Global);
        let dialog_action = self.bindings.lookup(&key, BindingScope::Dialog);
        let action = startup_action
            .or(center_action)
            .or(global_action)
            .or(dialog_action);

        if self
            .startup_log_viewer
            .as_ref()
            .is_some_and(|viewer| viewer.searching)
        {
            match action {
                Some(Action::CloseOverlay | Action::SearchToggle | Action::Confirm) => {
                    if let Some(viewer) = &mut self.startup_log_viewer {
                        viewer.searching = false;
                    }
                }
                _ => {
                    let changed = self
                        .startup_log_viewer
                        .as_mut()
                        .is_some_and(|viewer| viewer.search.handle_key(key));
                    if changed {
                        self.update_startup_log_search_scroll();
                    }
                }
            }
            return;
        }

        match action {
            Some(Action::CloseOverlay) => {
                self.close_top_overlay();
            }
            Some(Action::SearchToggle) => {
                if let Some(viewer) = &mut self.startup_log_viewer {
                    viewer.searching = true;
                }
            }
            Some(Action::ScrollPageDown) => {
                let page = self
                    .mouse_layout
                    .agent_term
                    .map(|area| area.height)
                    .unwrap_or(10);
                self.scroll_startup_log_viewer(page.max(1) as i16);
            }
            Some(Action::ScrollPageUp) => {
                let page = self
                    .mouse_layout
                    .agent_term
                    .map(|area| area.height)
                    .unwrap_or(10);
                self.scroll_startup_log_viewer(-(page.max(1) as i16));
            }
            Some(Action::ScrollLineDown | Action::MoveDown) => {
                self.scroll_startup_log_viewer(1);
            }
            Some(Action::ScrollLineUp | Action::MoveUp) => {
                self.scroll_startup_log_viewer(-1);
            }
            Some(Action::ScrollToBottom) => {
                let max_scroll = self.startup_log_viewer_max_scroll();
                if let Some(viewer) = &mut self.startup_log_viewer {
                    viewer.scroll_offset = max_scroll;
                }
            }
            Some(Action::ScrollToTop) => {
                if let Some(viewer) = &mut self.startup_log_viewer {
                    viewer.scroll_offset = 0;
                }
            }
            Some(Action::OpenStartupCommandLogFile) => {
                self.open_selected_startup_command_log();
            }
            Some(Action::OpenStartupCommandLogFolder) => {
                self.open_selected_startup_command_log_folder();
            }
            _ => {}
        }
    }

    fn handle_files_search_key(&mut self, key: KeyEvent) {
        match key.code {
            KeyCode::Esc => {
                self.clear_files_search();
                return;
            }
            KeyCode::Enter => {
                self.files_search_active = false;
                return;
            }
            _ => {}
        }
        if self.files_search.handle_key(key) {
            let query = self.files_search.text.clone();
            let found_match = self.update_files_search(query);
            if !found_match && self.has_files_search() {
                self.set_info("No file matches the current search.");
            }
        }
    }

    fn handle_files_key(&mut self, key: KeyEvent) -> Result<()> {
        if self.files_search_active {
            self.handle_files_search_key(key);
            return Ok(());
        }

        if let Some(action) = self.bindings.lookup(&key, BindingScope::Files) {
            match action {
                Action::MoveDown if self.right_section != RightSection::CommitInput => {
                    let len = self.current_files_len();
                    if self.files_index + 1 < len {
                        self.files_index += 1;
                    }
                }
                Action::MoveUp
                    if self.right_section != RightSection::CommitInput && self.files_index > 0 =>
                {
                    self.files_index -= 1;
                }
                Action::StageUnstage if self.right_section != RightSection::CommitInput => {
                    self.toggle_stage_selected_file()?;
                }
                Action::CommitChanges if !self.staged_files.is_empty() => {
                    self.execute_commit()?;
                }
                Action::OpenDiff => {
                    if self.right_section == RightSection::CommitInput {
                        if !self.staged_files.is_empty() {
                            self.input_target = InputTarget::CommitMessage;
                        }
                    } else {
                        self.open_diff_for_selected_file()?;
                    }
                }
                Action::DiscardChanges if self.right_section != RightSection::CommitInput => {
                    self.confirm_discard_selected_file()?;
                }
                Action::GenerateCommitMessage => {
                    self.trigger_ai_commit_message()?;
                }
                Action::EngageCommitInput if !self.staged_files.is_empty() => {
                    self.input_target = InputTarget::CommitMessage;
                }
                Action::PushToRemote => {
                    self.push_to_remote()?;
                }
                Action::PullFromRemote => {
                    self.pull_from_remote()?;
                }
                Action::SearchFiles => {
                    self.files_search_active = true;
                }
                Action::SearchNext if !self.advance_files_search_match() => {
                    self.set_info("No active file search matches.");
                }
                _ => {}
            }
        } else if key.code == KeyCode::Esc && self.has_files_search() {
            self.clear_files_search();
        }
        Ok(())
    }

    fn handle_commit_input_key(&mut self, key: KeyEvent) -> Result<()> {
        // Exit actions are dispatched via bindings; text input stays hardcoded.
        if let Some(Action::ExitCommitInput) = self.bindings.lookup(&key, BindingScope::CommitInput)
        {
            self.input_target = InputTarget::None;
            return Ok(());
        }
        // TextInput handles Enter (newline), Up/Down (line nav), and all
        // editing keys in multiline mode.
        self.commit_input.handle_key(key);
        Ok(())
    }

    fn handle_startup_command_input_key(&mut self, key: KeyEvent) -> Result<()> {
        if let Some(Action::ExitCommitInput) = self.bindings.lookup(&key, BindingScope::CommitInput)
        {
            self.input_target = InputTarget::None;
            return Ok(());
        }
        if let Some(input) = match &mut self.prompt {
            PromptState::ConfigureStartupCommand { input, .. }
            | PromptState::ConfigureProjectEnv { input, .. }
            | PromptState::ConfigureGlobalEnv { input, .. } => Some(input),
            _ => None,
        } {
            if key.code == KeyCode::Char('d') && key.modifiers.contains(KeyModifiers::CONTROL) {
                input.clear();
            } else {
                input.handle_key(key);
            }
        } else {
            self.input_target = InputTarget::None;
        }
        Ok(())
    }

    fn handle_macro_bar_key(&mut self, key: KeyEvent) -> Result<bool> {
        match key.code {
            KeyCode::Esc => {
                self.close_macro_bar();
            }
            KeyCode::Enter => {
                let (query, selected) = if let Some(bar) = &self.macro_bar {
                    (bar.input.text.clone(), bar.selected)
                } else {
                    return Ok(false);
                };
                let filtered = self.filtered_macros(&query);
                if let Some(&(name, text)) = filtered.get(selected) {
                    if let Some(provider) = self.selected_terminal_surface_client() {
                        let payload = macro_payload_bytes(text);
                        let _ = provider.write_bytes(&payload);
                    }
                    let name = name.to_string();
                    self.set_info(format!("Sent macro \"{name}\"."));
                }
                self.close_macro_bar();
            }
            KeyCode::Up => {
                if let Some(bar) = &mut self.macro_bar {
                    bar.selected = bar.selected.saturating_sub(1);
                }
            }
            KeyCode::Down => {
                let count = if let Some(bar) = &self.macro_bar {
                    let query = bar.input.text.clone();
                    self.filtered_macros(&query).len()
                } else {
                    0
                };
                if let Some(bar) = &mut self.macro_bar {
                    bar.selected = (bar.selected + 1).min(count.saturating_sub(1));
                }
            }
            KeyCode::Tab => {
                if let Some(bar) = &self.macro_bar {
                    let query = bar.input.text.clone();
                    let selected = bar.selected;
                    let filtered = self.filtered_macros(&query);
                    if let Some(&(name, _)) = filtered.get(selected) {
                        let name = name.to_string();
                        if let Some(bar) = &mut self.macro_bar {
                            bar.input.set_text(name);
                            bar.selected = 0;
                        }
                    }
                }
            }
            _ => {
                if let Some(bar) = &mut self.macro_bar {
                    let changed = bar.input.handle_key(key);
                    if changed {
                        bar.selected = 0;
                    }
                }
            }
        }
        Ok(false)
    }

    fn close_macro_bar(&mut self) {
        if let Some(bar) = self.macro_bar.take() {
            self.input_target = bar.previous_input_target;
        }
    }

    fn toggle_stage_selected_file(&mut self) -> Result<()> {
        let Some(session) = self.selected_session() else {
            self.set_error("Select a session first.");
            return Ok(());
        };
        let worktree = PathBuf::from(&session.worktree_path);
        let file = match self.right_section {
            RightSection::Staged => self.staged_files.get(self.files_index),
            RightSection::Unstaged => self.unstaged_files.get(self.files_index),
            RightSection::CommitInput => return Ok(()),
        };
        let Some(file) = file else { return Ok(()) };
        let path = file.path.clone();
        match self.right_section {
            RightSection::Unstaged => {
                git::stage_file(&worktree, &path)?;
            }
            RightSection::Staged => {
                git::unstage_file(&worktree, &path)?;
            }
            RightSection::CommitInput => {}
        }
        self.reload_changed_files();
        // If the section we were in is now empty, move to the other one.
        if self.right_section == RightSection::Staged && self.staged_files.is_empty() {
            self.right_section = RightSection::Unstaged;
            self.clamp_files_cursor();
        } else if self.right_section == RightSection::Unstaged && self.unstaged_files.is_empty() {
            self.right_section = RightSection::Staged;
            self.clamp_files_cursor();
        }
        Ok(())
    }

    fn confirm_discard_selected_file(&mut self) -> Result<()> {
        let file = match self.right_section {
            RightSection::Unstaged => self.unstaged_files.get(self.files_index),
            RightSection::Staged => {
                self.set_error("Unstage the file first to discard changes.");
                return Ok(());
            }
            RightSection::CommitInput => return Ok(()),
        };
        let Some(file) = file else { return Ok(()) };
        self.prompt = PromptState::ConfirmDiscardFile {
            file_path: file.path.clone(),
            is_untracked: file.status == "?",
            confirm_selected: false,
        };
        Ok(())
    }

    fn trigger_ai_commit_message(&mut self) -> Result<()> {
        if self.staged_files.is_empty() {
            self.set_error("Stage files first.");
            return Ok(());
        }
        if self.commit_input.overlay().is_some() {
            return Ok(());
        }
        let Some(session) = self.selected_session() else {
            self.set_error("Select a session first.");
            return Ok(());
        };
        let worktree = PathBuf::from(&session.worktree_path);
        let base_prompt = self.config.default_commit_prompt();

        // Capture the staged diff up-front so the provider does not need tool
        // access to inspect it. The diff is appended after the prompt text.
        let diff_text = match git::staged_diff_text(&worktree) {
            Ok(d) if d.is_empty() => {
                self.set_error("No staged diff found.");
                return Ok(());
            }
            Ok(d) => d,
            Err(e) => {
                self.set_error(format!("Failed to read staged diff: {e}"));
                return Ok(());
            }
        };
        let prompt = format!("{base_prompt}\n\n{diff_text}");

        let cfg = provider_config(&self.config, &session.provider);
        let prov = provider::create_provider(session.provider.as_str(), cfg);
        let tx = self.worker_tx.clone();
        self.commit_input
            .set_overlay("Generating commit message\u{2026}");
        self.set_busy("Generating AI commit message from staged diff\u{2026}");
        thread::spawn(move || match prov.run_oneshot(&prompt, &worktree) {
            Ok(msg) => {
                let _ = tx.send(WorkerEvent::CommitMessageGenerated(msg));
            }
            Err(e) => {
                let _ = tx.send(WorkerEvent::CommitMessageFailed(e.to_string()));
            }
        });
        Ok(())
    }

    fn execute_commit(&mut self) -> Result<()> {
        if self.staged_files.is_empty() {
            self.set_error("No staged changes to commit.");
            return Ok(());
        }
        if self.commit_input.text.trim().is_empty() {
            self.set_error("Enter a commit message first.");
            return Ok(());
        }
        let Some(session) = self.selected_session() else {
            self.set_error("Select a session first.");
            return Ok(());
        };
        let worktree = PathBuf::from(&session.worktree_path);
        match git::commit(&worktree, &self.commit_input.text) {
            Ok(_) => {
                self.commit_input.clear();
                let push_key = self.bindings.label_for(Action::PushToRemote);
                let ai_key = self.bindings.label_for(Action::GenerateCommitMessage);
                self.set_info(format!("Changes committed successfully. Press {push_key} to push to remote, or {ai_key} to generate an AI message."));
                self.reload_changed_files();
            }
            Err(e) => self.set_error(format!("Commit failed: {e}")),
        }
        Ok(())
    }

    fn push_to_remote(&mut self) -> Result<()> {
        let Some(session) = self.selected_session() else {
            self.set_error("Select a session first.");
            return Ok(());
        };
        let worktree = PathBuf::from(&session.worktree_path);
        let tx = self.worker_tx.clone();
        self.set_busy("Pushing to remote…");
        thread::spawn(move || {
            let result = git::push(&worktree).map(|_| ()).map_err(|e| e.to_string());
            let _ = tx.send(WorkerEvent::PushCompleted(result));
        });
        Ok(())
    }

    pub(crate) fn start_pull(
        &mut self,
        repo_path: PathBuf,
        target: PullTarget,
        busy_message: impl Into<String>,
        already_running_message: impl Into<String>,
    ) {
        let repo_key = repo_path.to_string_lossy().into_owned();
        if !self.pulls_in_flight.insert(repo_key.clone()) {
            self.set_warning(already_running_message);
            return;
        }

        let tx = self.worker_tx.clone();
        self.set_busy(busy_message);
        thread::spawn(move || {
            let result = match &target {
                PullTarget::Project { leading_branch, .. } => {
                    let leading_branch = match leading_branch.clone() {
                        Some(branch) => Ok(branch),
                        None => git::current_branch(&repo_path)
                            .map(|branch| leading_branch_for_project(&repo_path, &branch)),
                    };
                    leading_branch
                        .and_then(|branch| {
                            git::switch_branch_if_needed(&repo_path, &branch)?;
                            if git::has_tracked_changes(&repo_path)? {
                                return Err(anyhow::anyhow!(
                                    "Refresh blocked because the source checkout has uncommitted changes."
                                ));
                            }
                            git::pull_branch(&repo_path, &branch)
                        })
                        .map(|_| git::current_branch(&repo_path).ok())
                        .map_err(|e| e.to_string())
                }
                PullTarget::Session => git::pull_current_branch(&repo_path)
                    .map(|_| None)
                    .map_err(|e| e.to_string()),
            };
            let _ = tx.send(WorkerEvent::PullCompleted {
                repo_path: repo_key,
                target,
                result,
            });
        });
    }

    fn pull_from_remote(&mut self) -> Result<()> {
        let Some(session) = self.selected_session() else {
            self.set_error("Select a session first.");
            return Ok(());
        };
        self.start_pull(
            PathBuf::from(&session.worktree_path),
            PullTarget::Session,
            "Pulling latest changes from remote…",
            "Pull already in progress for this worktree. Wait for the current pull to finish.",
        );
        Ok(())
    }

    /// Read raw bytes from stdin, split into terminal sequences, and either
    /// handle intercepted bindings (exit interactive, scroll) or forward
    /// verbatim to the active PTY. This bypasses crossterm's event parser so
    /// all key combinations (Shift+Tab, Alt+Backspace, Ctrl+Right, F-keys,
    /// kitty keyboard protocol sequences, etc.) reach the child process.
    pub(crate) fn poll_and_forward_raw_input(&mut self) -> Result<bool> {
        use rustix::event::{PollFd, PollFlags, poll};
        use std::io::Read;
        use std::os::fd::AsFd;

        // Verify we have an active session/provider.
        if self.selected_session().is_none() {
            self.input_target = InputTarget::None;
            self.terminal_selection = None;
            self.in_bracket_paste = false;
            self.raw_input_buf.clear();
            self.raw_input_parser.clear();
            self.loading_input_buf.clear();
            return Ok(false);
        }
        let surface = self.session_surface;
        if self.selected_terminal_surface_client().is_none() {
            self.input_target = InputTarget::None;
            self.terminal_selection = None;
            self.in_bracket_paste = false;
            self.raw_input_buf.clear();
            self.raw_input_parser.clear();
            self.loading_input_buf.clear();
            let label = match surface {
                SessionSurface::Agent => "Agent",
                SessionSurface::Terminal => "Companion terminal",
            };
            self.set_error(format!("{label} disconnected."));
            return Ok(false);
        }

        // Poll stdin with 100ms timeout (matches the crossterm poll interval).
        let stdin_handle = std::io::stdin();
        let mut buf = [0u8; 4096];
        let timeout = rustix::time::Timespec {
            tv_sec: 0,
            tv_nsec: 100_000_000, // 100ms
        };
        let stdin_borrow = stdin_handle.as_fd();
        let ready = crate::io_retry::retry_on_interrupt_errno(|| {
            let mut pollfd = [PollFd::new(&stdin_borrow, PollFlags::IN)];
            poll(&mut pollfd, Some(&timeout))
        })?;
        if ready == 0 {
            if self.resolve_pending_bare_esc(&stdin_borrow, None, &mut buf)? {
                return Ok(false);
            }
            return Ok(false);
        }

        // Read available bytes from the same handle used for polling.
        let mut stdin_lock = stdin_handle.lock();
        let n = crate::io_retry::retry_on_interrupt(|| stdin_lock.read(&mut buf))?;
        if n == 0 {
            return Ok(false);
        }

        // Don't forward input until the agent has produced visible output.
        // Keystrokes during the loading phase would reach a process that
        // isn't ready for them. We still drain stdin above to prevent
        // buffer accumulation. However, we still honour ExitInteractive so
        // the user can minimize a loading agent.
        //
        // Bytes are accumulated into a dedicated `loading_input_buf` (not
        // `raw_input_buf`) so that suppressed keystrokes typed during
        // loading cannot leak into the first post-loading
        // `process_raw_input_bytes` call when `has_output()` flips to true.
        // The buffer is capped at LOADING_INPUT_BUF_MAX so that pasting a
        // large amount of text (or an unterminated OSC sequence, which
        // split_sequences reports as an entirely-incomplete remainder)
        // cannot grow the buffer without bound.
        if let Some(provider) = self.selected_terminal_surface_client()
            && !provider.has_output()
        {
            append_capped(
                &mut self.loading_input_buf,
                &buf[..n],
                LOADING_INPUT_BUF_MAX,
            );
            if self.scan_loading_phase_exit() {
                return Ok(false);
            }
            // Trim to the incomplete remainder so a partial escape can be
            // completed by the next read (already bounded by the cap above).
            let (_, remainder) = crate::raw_input::split_sequences(&self.loading_input_buf);
            let keep = remainder.len();
            let start = self.loading_input_buf.len() - keep;
            self.loading_input_buf = self.loading_input_buf[start..].to_vec();
            return Ok(false);
        }

        // If we just transitioned out of loading, clear the loading buffer
        // so stale remainders don't accumulate across sessions.
        if !self.loading_input_buf.is_empty() {
            self.loading_input_buf.clear();
        }

        let should_exit = self.process_raw_input_bytes(&buf[..n])?;
        if should_exit {
            return Ok(true);
        }

        // Drain any remaining stdin data without returning to the main loop
        // (which would render between every 4096-byte chunk). This prevents
        // intermediate renders during large pastes while still respecting a
        // time budget so we don't starve the UI.
        let drain_deadline = std::time::Instant::now() + std::time::Duration::from_millis(16);
        let zero_timeout = rustix::time::Timespec {
            tv_sec: 0,
            tv_nsec: 0,
        };
        loop {
            if std::time::Instant::now() >= drain_deadline {
                break;
            }
            // If we exited interactive mode during processing, stop draining.
            if !matches!(
                self.input_target,
                InputTarget::Agent | InputTarget::Terminal
            ) {
                break;
            }
            // Poll stdin with zero timeout — return immediately if no data.
            let more = crate::io_retry::retry_on_interrupt_errno(|| {
                let mut pollfd = [PollFd::new(&stdin_borrow, PollFlags::IN)];
                poll(&mut pollfd, Some(&zero_timeout))
            })?;
            if more == 0 {
                break;
            }
            let n2 = crate::io_retry::retry_on_interrupt(|| stdin_lock.read(&mut buf))?;
            if n2 == 0 {
                break;
            }
            // Re-check output guard.
            if let Some(provider) = self.selected_terminal_surface_client()
                && !provider.has_output()
            {
                break;
            }
            let should_exit = self.process_raw_input_bytes(&buf[..n2])?;
            if should_exit {
                return Ok(true);
            }
        }

        if self.resolve_pending_bare_esc(&stdin_borrow, Some(&mut stdin_lock), &mut buf)? {
            return Ok(false);
        }

        Ok(false)
    }

    fn resolve_pending_bare_esc(
        &mut self,
        stdin_borrow: &impl std::os::fd::AsFd,
        stdin_lock: Option<&mut std::io::StdinLock<'_>>,
        buf: &mut [u8; 4096],
    ) -> Result<bool> {
        use rustix::event::{PollFd, PollFlags, poll};
        use std::io::Read;

        if self.raw_input_parser.pending() != [0x1b] {
            return Ok(false);
        }

        std::thread::sleep(ESC_AMBIGUITY_TIMEOUT);
        let zero_timeout = rustix::time::Timespec {
            tv_sec: 0,
            tv_nsec: 0,
        };
        let more = crate::io_retry::retry_on_interrupt_errno(|| {
            let mut pollfd = [PollFd::new(stdin_borrow, PollFlags::IN)];
            poll(&mut pollfd, Some(&zero_timeout))
        })?;
        if more != 0 {
            let n = if let Some(stdin_lock) = stdin_lock {
                crate::io_retry::retry_on_interrupt(|| stdin_lock.read(buf))?
            } else {
                let stdin_handle = std::io::stdin();
                let mut stdin_lock = stdin_handle.lock();
                crate::io_retry::retry_on_interrupt(|| stdin_lock.read(buf))?
            };
            if n != 0 {
                return self.process_raw_input_bytes(&buf[..n]);
            }
        }

        self.process_raw_input_bytes(&[])
    }

    /// Process raw bytes that have already been read from stdin.
    ///
    /// This is the core logic of interactive input handling, split out from
    /// `poll_and_forward_raw_input` so it can be tested without real stdin I/O.
    pub(crate) fn process_raw_input_bytes(&mut self, bytes: &[u8]) -> Result<bool> {
        // Keep compatibility with tests that still seed raw_input_buf
        // directly, while making the parser the owner of live pending bytes.
        if self.raw_input_buf.as_slice() != self.raw_input_parser.pending() {
            self.raw_input_parser
                .replace_pending(self.raw_input_buf.as_slice());
        }

        let mut sequences = self.raw_input_parser.feed_sequences(bytes);
        if bytes.is_empty()
            && let Some(seq) = self.raw_input_parser.resolve_pending_esc()
        {
            sequences.push(crate::raw_input::ParsedSequence {
                bytes: seq,
                in_bracket_paste: self.raw_input_parser.in_bracket_paste(),
            });
        }
        self.raw_input_buf = self.raw_input_parser.pending().to_vec();
        self.in_bracket_paste = self.raw_input_parser.in_bracket_paste();

        // Collect what to do for each sequence: an intercepted action, a
        // mouse event to handle in the UI, or raw bytes to forward.
        enum SeqAction {
            Intercept(Action, bool, Vec<u8>),
            Mouse(MouseEvent, Vec<u8>),
            Forward(Vec<u8>),
        }

        // Build actions with bracket-paste awareness: inside a paste
        // (between ESC[200~ and ESC[201~), skip intercept matching so
        // pasted text never triggers keybindings.
        let mut actions: Vec<SeqAction> = Vec::with_capacity(sequences.len());
        for parsed in &sequences {
            let seq = parsed.bytes.as_slice();
            if seq == crate::raw_input::BRACKET_PASTE_START {
                self.in_bracket_paste = self.raw_input_parser.in_bracket_paste();
                actions.push(SeqAction::Forward(seq.to_vec()));
                continue;
            }
            if seq == crate::raw_input::BRACKET_PASTE_END {
                self.in_bracket_paste = self.raw_input_parser.in_bracket_paste();
                actions.push(SeqAction::Forward(seq.to_vec()));
                continue;
            }
            if parsed.in_bracket_paste {
                actions.push(SeqAction::Forward(seq.to_vec()));
                continue;
            }
            // Mouse events must be handled by the UI, not forwarded to the
            // PTY. crossterm's EnableMouseCapture uses SGR (1006) encoding,
            // so terminal mouse events arrive as CSI `<…M` / `<…m`.
            if let Some(mouse_ev) = crate::raw_input::parse_sgr_mouse(seq) {
                actions.push(SeqAction::Mouse(mouse_ev, seq.to_vec()));
                continue;
            }
            if let Some((action, conditional)) = self.interactive_patterns.match_sequence(seq) {
                actions.push(SeqAction::Intercept(action, conditional, seq.to_vec()));
            } else {
                actions.push(SeqAction::Forward(seq.to_vec()));
            }
        }

        // Check once whether the user is scrolled back so we can suppress
        // non-scroll input for the entire batch.
        let is_scrolled_back = self
            .selected_terminal_surface_client()
            .is_some_and(|p| p.scrollback_offset() > 0);

        // Process collected actions, batching consecutive forward sequences
        // into a single PTY write to avoid per-character lock/write/flush
        // overhead (critical for large pastes).
        let mut forward_batch: Vec<u8> = Vec::new();

        /// Flush accumulated forward bytes to the PTY in a single write.
        fn flush_forward_batch(
            batch: &mut Vec<u8>,
            is_scrolled_back: bool,
            needs_selection_clear: &mut bool,
            provider: Option<&crate::pty::PtyClient>,
        ) {
            if batch.is_empty() {
                return;
            }
            *needs_selection_clear = true;
            if !is_scrolled_back && let Some(p) = provider {
                let _ = p.write_bytes(batch);
            }
            batch.clear();
        }

        // Track whether the selection should be cleared (set by flush or
        // individual forward writes). We defer the clear to avoid repeated
        // borrow-mut conflicts inside the loop.
        let mut needs_selection_clear = false;

        for action in actions {
            match action {
                SeqAction::Intercept(Action::OpenMacroBar, _, _) => {
                    flush_forward_batch(
                        &mut forward_batch,
                        is_scrolled_back,
                        &mut needs_selection_clear,
                        self.selected_terminal_surface_client(),
                    );
                    if is_scrolled_back {
                        continue;
                    }
                    if self.filtered_macros("").is_empty() {
                        self.set_info("No macros defined for this surface.");
                        self.raw_input_buf.clear();
                        self.raw_input_parser.clear();
                        return Ok(false);
                    }
                    let prev = self.input_target;
                    self.macro_bar = Some(MacroBarState {
                        input: TextInput::new(),
                        selected: 0,
                        previous_input_target: prev,
                    });
                    self.input_target = InputTarget::None;
                    self.terminal_selection = None;
                    self.raw_input_buf.clear();
                    self.raw_input_parser.clear();
                    return Ok(false);
                }
                SeqAction::Intercept(Action::ExitInteractive, _, _) => {
                    flush_forward_batch(
                        &mut forward_batch,
                        is_scrolled_back,
                        &mut needs_selection_clear,
                        self.selected_terminal_surface_client(),
                    );
                    self.exit_interactive_mode();
                    return Ok(false);
                }
                SeqAction::Intercept(Action::ScrollPageUp, _, _) => {
                    flush_forward_batch(
                        &mut forward_batch,
                        is_scrolled_back,
                        &mut needs_selection_clear,
                        self.selected_terminal_surface_client(),
                    );
                    if self.last_pty_size.0 > 0 {
                        self.scroll_pty(ScrollDirection::Up, self.last_pty_size.0 as usize);
                    }
                }
                SeqAction::Intercept(Action::ScrollPageDown, _, _) => {
                    flush_forward_batch(
                        &mut forward_batch,
                        is_scrolled_back,
                        &mut needs_selection_clear,
                        self.selected_terminal_surface_client(),
                    );
                    if self.last_pty_size.0 > 0 {
                        self.scroll_pty(ScrollDirection::Down, self.last_pty_size.0 as usize);
                    }
                }
                SeqAction::Intercept(Action::ScrollLineUp, conditional, raw) => {
                    let has_scrollback = self
                        .selected_terminal_surface_client()
                        .is_some_and(|p| p.scrollback_offset() > 0);
                    if conditional && has_scrollback && self.last_pty_size.0 > 0 {
                        flush_forward_batch(
                            &mut forward_batch,
                            is_scrolled_back,
                            &mut needs_selection_clear,
                            self.selected_terminal_surface_client(),
                        );
                        self.scroll_pty(ScrollDirection::Up, 1);
                    } else {
                        forward_batch.extend_from_slice(&raw);
                    }
                }
                SeqAction::Intercept(Action::ScrollLineDown, conditional, raw) => {
                    let has_scrollback = self
                        .selected_terminal_surface_client()
                        .is_some_and(|p| p.scrollback_offset() > 0);
                    if conditional && has_scrollback && self.last_pty_size.0 > 0 {
                        flush_forward_batch(
                            &mut forward_batch,
                            is_scrolled_back,
                            &mut needs_selection_clear,
                            self.selected_terminal_surface_client(),
                        );
                        self.scroll_pty(ScrollDirection::Down, 1);
                    } else {
                        forward_batch.extend_from_slice(&raw);
                    }
                }
                SeqAction::Intercept(Action::ScrollToBottom, conditional, raw) => {
                    let has_scrollback = self
                        .selected_terminal_surface_client()
                        .is_some_and(|p| p.scrollback_offset() > 0);
                    if conditional && has_scrollback {
                        flush_forward_batch(
                            &mut forward_batch,
                            is_scrolled_back,
                            &mut needs_selection_clear,
                            self.selected_terminal_surface_client(),
                        );
                        self.reset_pty_scrollback();
                    } else {
                        forward_batch.extend_from_slice(&raw);
                    }
                }
                SeqAction::Intercept(Action::ScrollToTop, conditional, raw) => {
                    let has_scrollback = self
                        .selected_terminal_surface_client()
                        .is_some_and(|p| p.scrollback_offset() > 0);
                    if conditional && has_scrollback {
                        flush_forward_batch(
                            &mut forward_batch,
                            is_scrolled_back,
                            &mut needs_selection_clear,
                            self.selected_terminal_surface_client(),
                        );
                        self.set_pty_scrollback_max();
                    } else {
                        forward_batch.extend_from_slice(&raw);
                    }
                }
                SeqAction::Mouse(mouse_ev, raw) => {
                    // Flush any pending forward bytes before handling mouse
                    // events — mouse actions may change scroll state or exit
                    // interactive mode.
                    flush_forward_batch(
                        &mut forward_batch,
                        is_scrolled_back,
                        &mut needs_selection_clear,
                        self.selected_terminal_surface_client(),
                    );
                    let is_scroll = matches!(
                        mouse_ev.kind,
                        MouseEventKind::ScrollUp
                            | MouseEventKind::ScrollDown
                            | MouseEventKind::ScrollLeft
                            | MouseEventKind::ScrollRight
                    );
                    if is_scroll {
                        self.terminal_selection = None;
                        // Check if the provider has forward_scroll enabled
                        // (only applies to agents, not companion terminals).
                        let forward = matches!(self.input_target, InputTarget::Agent)
                            && self
                                .selected_session()
                                .map(|s| provider_config(&self.config, &s.provider).forward_scroll)
                                .unwrap_or(false);
                        if forward {
                            if let Some(provider) = self.selected_terminal_surface_client() {
                                let _ = provider.write_bytes(&raw);
                            }
                        } else if self.handle_mouse(mouse_ev) {
                            return Ok(true);
                        }
                    } else {
                        // If the click landed outside the fullscreen overlay,
                        // exit interactive mode instead of forwarding to the PTY.
                        // This check runs regardless of scroll state so the user
                        // can always click outside to dismiss the overlay.
                        let outside_overlay =
                            matches!(mouse_ev.kind, MouseEventKind::Down(MouseButton::Left))
                                && !self.mouse_layout.agent_term.is_some_and(|rect| {
                                    contains_point(rect, mouse_ev.column, mouse_ev.row)
                                });
                        if outside_overlay {
                            self.exit_interactive_mode();
                            return Ok(false);
                        }

                        let child_wants_mouse = self
                            .selected_terminal_surface_client()
                            .is_some_and(|p| p.has_mouse_mode());
                        let shift_held = mouse_ev
                            .modifiers
                            .contains(crossterm::event::KeyModifiers::SHIFT);
                        let should_select = !child_wants_mouse || shift_held;

                        if should_select {
                            self.handle_terminal_selection_mouse(mouse_ev);
                        } else if child_wants_mouse
                            && !is_scrolled_back
                            && let Some(provider) = self.selected_terminal_surface_client()
                        {
                            // Translate screen-absolute coordinates to
                            // child-relative coordinates before forwarding.
                            // Without this, the child sees rows/cols offset
                            // by the terminal area's position on screen
                            // (header + borders), causing highlights to land
                            // several lines below the actual click.
                            if let Some(term_area) = self.mouse_layout.agent_term
                                && let Some(translated) = crate::raw_input::translate_sgr_mouse(
                                    &raw,
                                    term_area.x,
                                    term_area.y,
                                )
                            {
                                let _ = provider.write_bytes(&translated);
                            }
                        }
                    }
                }
                SeqAction::Intercept(_, _, raw) | SeqAction::Forward(raw) => {
                    // Unknown intercepted action or normal forward — batch
                    // for a single PTY write. In scroll mode, all non-scroll
                    // input is suppressed (the batch won't be flushed).
                    forward_batch.extend_from_slice(&raw);
                }
            }
        }

        // Flush any remaining batched forward bytes.
        flush_forward_batch(
            &mut forward_batch,
            is_scrolled_back,
            &mut needs_selection_clear,
            self.selected_terminal_surface_client(),
        );

        if needs_selection_clear {
            self.terminal_selection = None;
        }

        Ok(false)
    }

    fn handle_prompt_key(&mut self, key: KeyEvent) -> Result<bool> {
        // Any keystroke during a held mouse-press cancels the press —
        // mouse-up will see no pending press and will not fire an
        // action. This keeps the press visual from outliving its trigger
        // when the user reaches for the keyboard mid-click.
        self.pressed_button = None;
        if let PromptState::ResourceMonitor {
            scroll_offset,
            selected_row,
            expanded,
            rows,
            ..
        } = &mut self.prompt
        {
            if key.code == KeyCode::Esc {
                self.prompt = PromptState::None;
                return Ok(false);
            }
            let visual = build_visual_rows(rows, expanded);
            let max_row = visual.len().saturating_sub(1);
            match key.code {
                KeyCode::Up | KeyCode::Char('k') => {
                    *selected_row = selected_row.saturating_sub(1);
                }
                KeyCode::Down | KeyCode::Char('j') => {
                    *selected_row = (*selected_row + 1).min(max_row);
                }
                KeyCode::PageUp => {
                    *selected_row = selected_row.saturating_sub(10);
                }
                KeyCode::PageDown => {
                    *selected_row = (*selected_row + 10).min(max_row);
                }
                KeyCode::Home => {
                    *selected_row = 0;
                }
                KeyCode::End => {
                    *selected_row = max_row;
                }
                KeyCode::Enter | KeyCode::Char(' ') => {
                    if let Some(VisualRow::Parent(idx)) = visual.get(*selected_row) {
                        let stat = &rows[*idx];
                        if let Some(pid) = stat.pid
                            && !stat.children.is_empty()
                            && !expanded.remove(&pid)
                        {
                            expanded.insert(pid);
                        }
                    }
                }
                _ => {}
            }
            // Keep scroll_offset tracking the cursor.
            *scroll_offset = (*selected_row).saturating_sub(5) as u16;
            return Ok(false);
        }

        if let PromptState::DebugInput {
            lines,
            scroll_offset,
        } = &mut self.prompt
        {
            // Esc always closes — hardcoded so a broken binding can't trap the user.
            if key.code == KeyCode::Esc {
                self.prompt = PromptState::None;
                return Ok(false);
            }

            let kc = crokey::KeyCombination::from(key).normalized();
            let label = crate::keybindings::display_format().to_string(kc);

            // Look up what action this key resolves to in every scope.
            let resolved: Vec<String> = BindingScope::ALL
                .iter()
                .filter_map(|&scope| {
                    self.bindings
                        .lookup(&key, scope)
                        .map(|action| format!("{}: {}", scope.display_name(), action.config_name()))
                })
                .collect();
            let action_text = if resolved.is_empty() {
                "(none)".to_string()
            } else {
                resolved.join(", ")
            };

            let ts = Local::now().format("%H:%M:%S%.3f").to_string();
            lines.push(Line::from(vec![
                Span::styled(ts, Style::default().add_modifier(Modifier::DIM)),
                Span::raw(" │ "),
                Span::styled(
                    "Key   ",
                    Style::default().fg(self.theme.help_section_header_fg),
                ),
                Span::raw(" │ "),
                Span::styled(
                    format!("{:<18}", label),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::raw(" │ "),
                Span::styled(action_text, Style::default().add_modifier(Modifier::DIM)),
            ]));

            // Auto-scroll: keep the view pinned to the bottom.
            let total = lines.len() as u16;
            *scroll_offset = total;

            return Ok(false);
        }

        if matches!(self.prompt, PromptState::Command { .. }) {
            // Plain character keys always go to TextInput so j/k etc. can be
            // typed without conflicting with navigation bindings.
            let is_plain_char = matches!(key.code, KeyCode::Char(_))
                && !key.modifiers.contains(KeyModifiers::CONTROL);
            let action = if is_plain_char {
                None
            } else {
                self.bindings.lookup(&key, BindingScope::Palette)
            };

            match action {
                Some(Action::CloseOverlay) => {
                    self.prompt = PromptState::None;
                }
                Some(Action::MoveDown) => {
                    let count = match &self.prompt {
                        PromptState::Command { input, .. } => {
                            self.filtered_palette_commands(&input.text).len()
                        }
                        _ => 0,
                    };
                    if let PromptState::Command { selected, .. } = &mut self.prompt
                        && *selected + 1 < count
                    {
                        *selected += 1;
                    }
                }
                Some(Action::MoveUp) => {
                    if let PromptState::Command { selected, .. } = &mut self.prompt
                        && *selected > 0
                    {
                        *selected -= 1;
                    }
                }
                Some(Action::Confirm) => {
                    self.execute_selected_command_palette();
                }
                _ => {
                    // Text input fallback: Tab (autocomplete), then delegate to TextInput.
                    let tab_completion = if key.code == KeyCode::Tab {
                        match &self.prompt {
                            PromptState::Command {
                                input, selected, ..
                            } => self
                                .filtered_palette_commands(&input.text)
                                .get(*selected)
                                .and_then(|binding| binding.palette_name)
                                .map(str::to_string),
                            _ => None,
                        }
                    } else {
                        None
                    };
                    if let PromptState::Command {
                        input, selected, ..
                    } = &mut self.prompt
                    {
                        if let Some(command) = tab_completion {
                            input.set_text(command);
                            *selected = 0;
                        } else if key.code == KeyCode::Tab {
                            // No completion matched; leave the command text unchanged.
                        } else if input.handle_key(key) {
                            *selected = 0;
                        }
                    }
                }
            }
            return Ok(false);
        }

        if matches!(self.prompt, PromptState::KillRunning(..)) {
            let is_searching = matches!(
                self.prompt,
                PromptState::KillRunning(KillRunningPrompt {
                    searching: true,
                    ..
                })
            );
            let is_plain_char = matches!(key.code, KeyCode::Char(_))
                && !key.modifiers.contains(KeyModifiers::CONTROL);
            let action = if is_searching && is_plain_char {
                None
            } else {
                self.bindings.lookup(&key, BindingScope::RuntimeKill)
            };

            match action {
                Some(Action::CloseOverlay) => {
                    let mut closed = false;
                    if let PromptState::KillRunning(prompt) = &mut self.prompt {
                        if prompt.searching {
                            prompt.searching = false;
                        } else if !prompt.filter.is_empty() {
                            prompt.filter.clear();
                            Self::clamp_kill_running_prompt(prompt);
                        } else {
                            closed = true;
                        }
                    }
                    if closed {
                        self.prompt = PromptState::None;
                        self.set_info("Closed Kill Running. No agents or terminals were killed.");
                    }
                }
                Some(Action::SearchToggle) if !is_searching => {
                    if let PromptState::KillRunning(prompt) = &mut self.prompt {
                        prompt.filter.move_end();
                        prompt.searching = true;
                        prompt.focus = KillRunningFocus::List;
                    }
                }
                Some(Action::MoveDown) => {
                    if let PromptState::KillRunning(prompt) = &mut self.prompt
                        && matches!(prompt.focus, KillRunningFocus::List)
                    {
                        let count = Self::visible_kill_running_indices(prompt).len();
                        if prompt.hovered_visible_index + 1 < count {
                            prompt.hovered_visible_index += 1;
                        }
                    }
                }
                Some(Action::MoveUp) => {
                    if let PromptState::KillRunning(prompt) = &mut self.prompt
                        && matches!(prompt.focus, KillRunningFocus::List)
                        && prompt.hovered_visible_index > 0
                    {
                        prompt.hovered_visible_index -= 1;
                    }
                }
                Some(Action::FocusNext) => {
                    if let PromptState::KillRunning(prompt) = &mut self.prompt {
                        prompt.focus = match prompt.focus {
                            KillRunningFocus::List => {
                                Self::next_kill_running_footer_action(prompt, None, true)
                            }
                            KillRunningFocus::Footer(action) => {
                                Self::next_kill_running_footer_action(prompt, Some(action), true)
                            }
                        };
                    }
                }
                Some(Action::FocusPrev) => {
                    if let PromptState::KillRunning(prompt) = &mut self.prompt {
                        prompt.focus = match prompt.focus {
                            KillRunningFocus::List => {
                                Self::next_kill_running_footer_action(prompt, None, false)
                            }
                            KillRunningFocus::Footer(action) => {
                                Self::next_kill_running_footer_action(prompt, Some(action), false)
                            }
                        };
                    }
                }
                Some(Action::ToggleMarked) => {
                    self.toggle_hovered_kill_running_selection();
                }
                Some(Action::Confirm) => {
                    if is_searching {
                        if let PromptState::KillRunning(prompt) = &mut self.prompt {
                            prompt.searching = false;
                        }
                    } else {
                        let focus = match &self.prompt {
                            PromptState::KillRunning(prompt) => prompt.focus,
                            _ => KillRunningFocus::List,
                        };
                        match focus {
                            KillRunningFocus::List => self.toggle_hovered_kill_running_selection(),
                            KillRunningFocus::Footer(action) => {
                                self.execute_kill_running_footer_action(action)?;
                            }
                        }
                    }
                }
                _ => {
                    if is_searching && let PromptState::KillRunning(prompt) = &mut self.prompt {
                        if prompt.filter.handle_key(key) {
                            prompt.hovered_visible_index = 0;
                        }
                        Self::clamp_kill_running_prompt(prompt);
                    }
                }
            }
            return Ok(false);
        }

        if matches!(self.prompt, PromptState::BrowseProjects { .. }) {
            // Check sub-mode states and do binding lookup before mutable borrow.
            let is_editing_path = matches!(
                self.prompt,
                PromptState::BrowseProjects {
                    editing_path: true,
                    ..
                }
            );
            let is_searching = matches!(
                self.prompt,
                PromptState::BrowseProjects {
                    searching: true,
                    ..
                }
            );
            let is_plain_char = matches!(key.code, KeyCode::Char(_))
                && !key.modifiers.contains(KeyModifiers::CONTROL);

            // Path editor keeps text editing local, with one browser-scoped
            // escape hatch back to directory browsing.
            if is_editing_path {
                let path_editor_action = if is_plain_char {
                    None
                } else {
                    self.bindings.lookup(&key, BindingScope::Browser)
                };
                let mut add_path: Option<String> = None;
                let mut refresh_completions = false;
                if let PromptState::BrowseProjects {
                    editing_path,
                    path_input,
                    tab_completions,
                    tab_index,
                    ..
                } = &mut self.prompt
                {
                    match path_editor_action {
                        Some(Action::ExitPathEditorOnProjectAdd) => {
                            *editing_path = false;
                            path_input.clear();
                            tab_completions.clear();
                            *tab_index = 0;
                        }
                        _ => match key.code {
                            KeyCode::Esc => {
                                *editing_path = false;
                                path_input.clear();
                                tab_completions.clear();
                                *tab_index = 0;
                            }
                            KeyCode::Tab | KeyCode::BackTab | KeyCode::Up | KeyCode::Down => {
                                if tab_completions.is_empty() {
                                    *tab_completions =
                                        Self::path_editor_completion_candidates(&path_input.text);
                                    *tab_index = 0;
                                } else if key.code == KeyCode::Up || is_reverse_tab(key) {
                                    if *tab_index == 0 {
                                        *tab_index = tab_completions.len().saturating_sub(1);
                                    } else {
                                        *tab_index -= 1;
                                    }
                                } else if key.code == KeyCode::Down {
                                    *tab_index = (*tab_index + 1) % tab_completions.len();
                                }
                                if key.code == KeyCode::Tab
                                    && !is_reverse_tab(key)
                                    && let Some(completion) = tab_completions.get(*tab_index)
                                {
                                    path_input.set_text(completion.clone());
                                    refresh_completions = true;
                                }
                            }
                            KeyCode::Enter => {
                                add_path = Some(path_input.text.trim().to_string());
                            }
                            _ => {
                                if path_input.handle_key(key) {
                                    refresh_completions = true;
                                }
                            }
                        },
                    }
                }
                if refresh_completions {
                    self.refresh_path_editor_completions();
                }
                if let Some(path) = add_path {
                    self.add_project_from_browser_path(path);
                }
                return Ok(false);
            }

            // Browser normal/search mode — use binding lookup.
            let action = if is_searching && is_plain_char {
                None
            } else {
                self.bindings.lookup(&key, BindingScope::Browser)
            };

            match action {
                Some(Action::CloseOverlay) => {
                    if let PromptState::BrowseProjects {
                        searching,
                        filter,
                        selected,
                        ..
                    } = &mut self.prompt
                    {
                        if *searching {
                            *searching = false;
                        } else if !filter.is_empty() {
                            filter.clear();
                            *selected = 0;
                        } else {
                            self.prompt = PromptState::None;
                        }
                    }
                }
                Some(Action::SearchToggle) if !is_searching => {
                    if let PromptState::BrowseProjects {
                        filter, searching, ..
                    } = &mut self.prompt
                    {
                        filter.move_end();
                        *searching = true;
                    }
                }
                Some(Action::MoveDown) => {
                    if let PromptState::BrowseProjects {
                        entries,
                        selected,
                        filter,
                        ..
                    } = &mut self.prompt
                    {
                        let filtered_len = if filter.is_empty() {
                            entries.len()
                        } else {
                            let needle = filter.text.to_lowercase();
                            entries
                                .iter()
                                .filter(|e| e.label.to_lowercase().contains(&needle))
                                .count()
                        };
                        if *selected + 1 < filtered_len {
                            *selected += 1;
                        }
                    }
                }
                Some(Action::MoveUp) => {
                    if let PromptState::BrowseProjects { selected, .. } = &mut self.prompt
                        && *selected > 0
                    {
                        *selected -= 1;
                    }
                }
                Some(Action::GoToPath) if !is_searching => {
                    if let PromptState::BrowseProjects {
                        current_dir,
                        editing_path,
                        path_input,
                        ..
                    } = &mut self.prompt
                    {
                        *editing_path = true;
                        let mut p = current_dir.to_string_lossy().to_string();
                        if !p.ends_with('/') {
                            p.push('/');
                        }
                        path_input.set_text(p);
                    }
                    self.refresh_path_editor_completions();
                }
                Some(Action::Confirm) if is_searching => {
                    if let PromptState::BrowseProjects { searching, .. } = &mut self.prompt {
                        *searching = false;
                    }
                }
                Some(Action::OpenEntry) if !is_searching => {
                    self.open_selected_browser_entry();
                }
                Some(Action::AddCurrentDir) if !is_searching => {
                    if let PromptState::BrowseProjects { current_dir, .. } = &self.prompt {
                        let path = current_dir.to_string_lossy().to_string();
                        self.add_project_from_browser_path(path);
                    }
                }
                _ => {
                    // Text input fallback for search mode.
                    if is_searching
                        && let PromptState::BrowseProjects {
                            filter, selected, ..
                        } = &mut self.prompt
                        && filter.handle_key(key)
                    {
                        *selected = 0;
                    }
                }
            }
            return Ok(false);
        }

        if let PromptState::PickEditor {
            editors, selected, ..
        } = &mut self.prompt
        {
            match self.bindings.lookup(&key, BindingScope::Palette) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::MoveDown) if *selected + 1 < editors.len() => {
                    *selected += 1;
                }
                Some(Action::MoveUp) if *selected > 0 => {
                    *selected -= 1;
                }
                Some(Action::Confirm) => {
                    self.open_selected_pick_editor();
                }
                _ => {}
            }
            return Ok(false);
        }

        if let PromptState::PickProjectWorktree(prompt) = &mut self.prompt {
            match self.bindings.lookup(&key, BindingScope::Palette) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::MoveDown) => {
                    let selectable = selectable_project_worktree_indices(&prompt.entries);
                    if let Some(current) = prompt.selected
                        && let Some(position) = selectable.iter().position(|idx| *idx == current)
                        && let Some(next) = selectable.get(position + 1)
                    {
                        prompt.selected = Some(*next);
                    } else if prompt.selected.is_none() {
                        prompt.selected = selectable.into_iter().next();
                    }
                }
                Some(Action::MoveUp) => {
                    let selectable = selectable_project_worktree_indices(&prompt.entries);
                    if let Some(current) = prompt.selected
                        && let Some(position) = selectable.iter().position(|idx| *idx == current)
                        && position > 0
                    {
                        prompt.selected = Some(selectable[position - 1]);
                    } else if prompt.selected.is_none() {
                        prompt.selected = selectable.into_iter().next();
                    }
                }
                Some(Action::Confirm) => {
                    self.open_selected_project_worktree_agent_prompt()?;
                }
                _ => {}
            }
            return Ok(false);
        }

        if let PromptState::ChangeAgentProvider(prompt) = &mut self.prompt {
            let palette_action = self.bindings.lookup(&key, BindingScope::Palette);
            let dialog_action = self.bindings.lookup(&key, BindingScope::Dialog);
            let is_space = key.code == KeyCode::Char(' ');
            let reverse_tab = is_reverse_tab(key);

            if matches!(palette_action.or(dialog_action), Some(Action::CloseOverlay)) {
                self.prompt = PromptState::None;
                return Ok(false);
            }

            if reverse_tab {
                prompt.focus = Self::next_change_agent_provider_focus(prompt.focus, false);
                return Ok(false);
            }

            match prompt.focus {
                ChangeAgentProviderFocus::List => match palette_action.or(dialog_action) {
                    Some(Action::MoveDown) if prompt.selected + 1 < prompt.options.len() => {
                        prompt.selected += 1;
                    }
                    Some(Action::MoveUp) if prompt.selected > 0 => {
                        prompt.selected -= 1;
                    }
                    Some(Action::Confirm) => {
                        self.apply_change_agent_provider()?;
                    }
                    Some(Action::ToggleSelection) => {
                        prompt.focus = Self::next_change_agent_provider_focus(prompt.focus, true);
                    }
                    _ => {}
                },
                ChangeAgentProviderFocus::Cancel | ChangeAgentProviderFocus::Apply => {
                    if matches!(dialog_action, Some(Action::ToggleSelection)) {
                        prompt.focus = Self::next_change_agent_provider_focus(prompt.focus, true);
                        return Ok(false);
                    }
                    if matches!(dialog_action, Some(Action::Confirm)) || is_space {
                        match prompt.focus {
                            ChangeAgentProviderFocus::Cancel => {
                                self.prompt = PromptState::None;
                            }
                            ChangeAgentProviderFocus::Apply => {
                                self.apply_change_agent_provider()?;
                            }
                            ChangeAgentProviderFocus::List => {}
                        }
                    }
                }
            }
            return Ok(false);
        }

        if let PromptState::ChangeDefaultProvider(prompt) = &mut self.prompt {
            let palette_action = self.bindings.lookup(&key, BindingScope::Palette);
            let dialog_action = self.bindings.lookup(&key, BindingScope::Dialog);
            let is_space = key.code == KeyCode::Char(' ');
            let reverse_tab = is_reverse_tab(key);

            if matches!(palette_action.or(dialog_action), Some(Action::CloseOverlay)) {
                self.prompt = PromptState::None;
                return Ok(false);
            }

            if reverse_tab {
                prompt.focus = Self::next_change_default_provider_focus(prompt.focus, false);
                return Ok(false);
            }

            match prompt.focus {
                ChangeDefaultProviderFocus::List => match palette_action.or(dialog_action) {
                    Some(Action::MoveDown) if prompt.selected + 1 < prompt.options.len() => {
                        prompt.selected += 1;
                    }
                    Some(Action::MoveUp) if prompt.selected > 0 => {
                        prompt.selected -= 1;
                    }
                    Some(Action::Confirm) => {
                        self.apply_change_default_provider()?;
                    }
                    Some(Action::ToggleSelection) => {
                        prompt.focus = Self::next_change_default_provider_focus(prompt.focus, true);
                    }
                    _ => {}
                },
                ChangeDefaultProviderFocus::Cancel | ChangeDefaultProviderFocus::Apply => {
                    if matches!(dialog_action, Some(Action::ToggleSelection)) {
                        prompt.focus = Self::next_change_default_provider_focus(prompt.focus, true);
                        return Ok(false);
                    }
                    if matches!(dialog_action, Some(Action::Confirm)) || is_space {
                        match prompt.focus {
                            ChangeDefaultProviderFocus::Cancel => {
                                self.prompt = PromptState::None;
                            }
                            ChangeDefaultProviderFocus::Apply => {
                                self.apply_change_default_provider()?;
                            }
                            ChangeDefaultProviderFocus::List => {}
                        }
                    }
                }
            }
            return Ok(false);
        }

        if let PromptState::ChangeProjectDefaultProvider(prompt) = &mut self.prompt {
            let palette_action = self.bindings.lookup(&key, BindingScope::Palette);
            let dialog_action = self.bindings.lookup(&key, BindingScope::Dialog);
            let is_space = key.code == KeyCode::Char(' ');
            let reverse_tab = key.code == KeyCode::BackTab;

            if matches!(palette_action.or(dialog_action), Some(Action::CloseOverlay)) {
                self.prompt = PromptState::None;
                return Ok(false);
            }

            if reverse_tab {
                prompt.focus = Self::next_change_default_provider_focus(prompt.focus, false);
                return Ok(false);
            }

            match prompt.focus {
                ChangeDefaultProviderFocus::List => match palette_action.or(dialog_action) {
                    Some(Action::MoveDown) if prompt.selected + 1 < prompt.options.len() => {
                        prompt.selected += 1;
                    }
                    Some(Action::MoveUp) if prompt.selected > 0 => {
                        prompt.selected -= 1;
                    }
                    Some(Action::Confirm) => {
                        self.apply_change_project_default_provider()?;
                    }
                    Some(Action::ToggleSelection) => {
                        prompt.focus = Self::next_change_default_provider_focus(prompt.focus, true);
                    }
                    _ => {}
                },
                ChangeDefaultProviderFocus::Cancel | ChangeDefaultProviderFocus::Apply => {
                    if matches!(dialog_action, Some(Action::ToggleSelection)) {
                        prompt.focus = Self::next_change_default_provider_focus(prompt.focus, true);
                        return Ok(false);
                    }
                    if matches!(dialog_action, Some(Action::Confirm)) || is_space {
                        match prompt.focus {
                            ChangeDefaultProviderFocus::Cancel => {
                                self.prompt = PromptState::None;
                            }
                            ChangeDefaultProviderFocus::Apply => {
                                self.apply_change_project_default_provider()?;
                            }
                            ChangeDefaultProviderFocus::List => {}
                        }
                    }
                }
            }
            return Ok(false);
        }

        if let PromptState::ChangeTheme(prompt) = &mut self.prompt {
            let palette_action = self.bindings.lookup(&key, BindingScope::Palette);
            let dialog_action = self.bindings.lookup(&key, BindingScope::Dialog);

            if matches!(palette_action.or(dialog_action), Some(Action::CloseOverlay)) {
                self.cancel_change_theme();
                return Ok(false);
            }

            let mut moved = false;
            match palette_action.or(dialog_action) {
                Some(Action::MoveDown) if prompt.selected + 1 < prompt.options.len() => {
                    prompt.selected += 1;
                    moved = true;
                }
                Some(Action::MoveUp) if prompt.selected > 0 => {
                    prompt.selected -= 1;
                    moved = true;
                }
                Some(Action::Confirm) => {
                    self.apply_change_theme()?;
                }
                _ => {}
            }
            if moved {
                self.preview_change_theme_selection();
            }
            return Ok(false);
        }

        if matches!(
            self.prompt,
            PromptState::ConfigureStartupCommand { .. }
                | PromptState::ConfigureProjectEnv { .. }
                | PromptState::ConfigureGlobalEnv { .. }
        ) && self.input_target == InputTarget::StartupCommand
        {
            self.handle_startup_command_input_key(key)?;
            return Ok(false);
        }

        if matches!(
            self.prompt,
            PromptState::ConfigureStartupCommand { .. }
                | PromptState::ConfigureProjectEnv { .. }
                | PromptState::ConfigureGlobalEnv { .. }
        ) {
            let palette_action = self.bindings.lookup(&key, BindingScope::Palette);
            let dialog_action = self.bindings.lookup(&key, BindingScope::Dialog);
            let files_action = self.bindings.lookup(&key, BindingScope::Files);
            if key.code == KeyCode::Char('d') && key.modifiers.contains(KeyModifiers::CONTROL) {
                if let Some(input) = configure_project_text_input_mut(&mut self.prompt) {
                    input.clear();
                }
                return Ok(false);
            }
            match palette_action.or(dialog_action).or(files_action) {
                Some(Action::CloseOverlay) => {
                    self.prompt = PromptState::None;
                    self.input_target = InputTarget::None;
                }
                Some(Action::Confirm) => {
                    if matches!(self.prompt, PromptState::ConfigureGlobalEnv { .. }) {
                        self.apply_configure_global_env()?;
                    } else if matches!(self.prompt, PromptState::ConfigureProjectEnv { .. }) {
                        self.apply_configure_project_env()?;
                    } else {
                        self.apply_configure_startup_command()?;
                    }
                }
                Some(Action::EngageCommitInput) => {
                    self.input_target = InputTarget::StartupCommand;
                    if let Some(input) = configure_project_text_input_mut(&mut self.prompt) {
                        input.move_end();
                    }
                }
                _ => {
                    if matches!(
                        key.code,
                        KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete
                    ) {
                        self.input_target = InputTarget::StartupCommand;
                        if let Some(input) = configure_project_text_input_mut(&mut self.prompt) {
                            input.handle_key(key);
                        }
                    }
                }
            }
            return Ok(false);
        }

        if let PromptState::StartupCommandLogs(prompt) = &mut self.prompt {
            let palette_action = self.bindings.lookup(&key, BindingScope::Palette);
            let dialog_action = self.bindings.lookup(&key, BindingScope::Dialog);
            let center_action = self.bindings.lookup(&key, BindingScope::Center);
            let help_action = self.bindings.lookup(&key, BindingScope::Help);
            let startup_logs_action = self.bindings.lookup(&key, BindingScope::StartupCommandLogs);
            let body = match self.overlay_layout.active {
                OverlayMouseLayout::StartupCommandLogs { body, .. } if body.height > 0 => {
                    Some(body)
                }
                _ => None,
            };
            let page = body.map(|body| body.height.max(1)).unwrap_or(10) as i16;
            if prompt.searching {
                let mut select_after_filter = None;
                match startup_logs_action.or(dialog_action) {
                    Some(Action::CloseOverlay | Action::SearchToggle | Action::Confirm) => {
                        prompt.searching = false;
                    }
                    _ if matches!(
                        key.code,
                        KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete
                    ) =>
                    {
                        prompt.filter.handle_key(key);
                        select_after_filter = Self::startup_command_log_filtered_indices(prompt)
                            .first()
                            .copied();
                    }
                    _ => {}
                }
                if let Some(index) = select_after_filter {
                    self.select_startup_command_log(index);
                }
                return Ok(false);
            }

            match startup_logs_action
                .or(palette_action)
                .or(dialog_action)
                .or(center_action)
                .or(help_action)
            {
                Some(Action::CloseOverlay) => {
                    self.prompt = PromptState::None;
                    self.startup_log_selection = None;
                }
                Some(Action::SearchToggle) => {
                    prompt.searching = true;
                }
                Some(Action::MoveDown) => {
                    let visible = Self::startup_command_log_filtered_indices(prompt);
                    let visual = Self::startup_command_log_selected_visual_index(prompt, &visible)
                        .unwrap_or(0);
                    if let Some(selected) = visible.get(visual + 1).copied() {
                        self.select_startup_command_log(selected);
                    }
                }
                Some(Action::MoveUp) => {
                    let visible = Self::startup_command_log_filtered_indices(prompt);
                    let visual = Self::startup_command_log_selected_visual_index(prompt, &visible)
                        .unwrap_or(0);
                    if visual > 0
                        && let Some(selected) = visible.get(visual - 1).copied()
                    {
                        self.select_startup_command_log(selected);
                    }
                }
                Some(Action::ScrollPageDown) => {
                    scroll_startup_command_log(prompt, body, page);
                }
                Some(Action::ScrollPageUp) => {
                    scroll_startup_command_log(prompt, body, -page);
                }
                Some(Action::ScrollLineDown) => {
                    scroll_startup_command_log(prompt, body, 1);
                }
                Some(Action::ScrollLineUp) => {
                    scroll_startup_command_log(prompt, body, -1);
                }
                Some(Action::OpenStartupCommandLogFile | Action::OpenEntry | Action::Confirm) => {
                    self.open_selected_startup_command_log();
                }
                Some(Action::OpenStartupCommandLogFolder) => {
                    self.open_selected_startup_command_log_folder();
                }
                _ => {}
            }
            return Ok(false);
        }

        if let PromptState::ConfigReloadFailed {
            recover_old_config,
            focus,
            ..
        } = &mut self.prompt
        {
            if is_reverse_tab(key) {
                *focus = match *focus {
                    ConfigReloadFailedFocus::Close => ConfigReloadFailedFocus::Checkbox,
                    ConfigReloadFailedFocus::Apply => ConfigReloadFailedFocus::Close,
                    ConfigReloadFailedFocus::Checkbox => ConfigReloadFailedFocus::Apply,
                };
                return Ok(false);
            }
            match self.bindings.lookup(&key, BindingScope::Dialog) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::ToggleSelection) => {
                    *focus = match *focus {
                        ConfigReloadFailedFocus::Close => ConfigReloadFailedFocus::Apply,
                        ConfigReloadFailedFocus::Apply => ConfigReloadFailedFocus::Checkbox,
                        ConfigReloadFailedFocus::Checkbox => ConfigReloadFailedFocus::Close,
                    };
                }
                Some(Action::Confirm) => match *focus {
                    ConfigReloadFailedFocus::Checkbox => {
                        *recover_old_config = !*recover_old_config;
                    }
                    ConfigReloadFailedFocus::Close => {
                        return Ok(self.resolve_config_reload_failed(false));
                    }
                    ConfigReloadFailedFocus::Apply => {
                        return Ok(self.resolve_config_reload_failed(true));
                    }
                },
                _ if key.code == KeyCode::Char(' ') => match *focus {
                    ConfigReloadFailedFocus::Checkbox => {
                        *recover_old_config = !*recover_old_config;
                    }
                    ConfigReloadFailedFocus::Close => {
                        return Ok(self.resolve_config_reload_failed(false));
                    }
                    ConfigReloadFailedFocus::Apply => {
                        return Ok(self.resolve_config_reload_failed(true));
                    }
                },
                _ => {}
            }
            return Ok(false);
        }

        if matches!(self.prompt, PromptState::AddProjectFailed { .. }) {
            let action = self.bindings.lookup(&key, BindingScope::Dialog);
            let is_space = key.code == KeyCode::Char(' ');
            if matches!(action, Some(Action::Confirm | Action::CloseOverlay)) || is_space {
                self.resolve_add_project_failed();
            }
            return Ok(false);
        }

        if let PromptState::ConfirmKillRunning(confirm_prompt) = &mut self.prompt {
            match self.bindings.lookup(&key, BindingScope::Dialog) {
                Some(Action::CloseOverlay) => {
                    let previous = confirm_prompt.previous.clone();
                    self.prompt = PromptState::KillRunning(previous);
                    self.set_info(
                        "Kill cancelled. Your running agents and companion terminals are unchanged.",
                    );
                }
                Some(Action::ToggleSelection) => {
                    confirm_prompt.confirm_selected = !confirm_prompt.confirm_selected;
                }
                Some(Action::Confirm) => {
                    let confirm = confirm_prompt.confirm_selected;
                    return Ok(self.resolve_confirm_kill_running(confirm));
                }
                _ if key.code == KeyCode::Char(' ') => {
                    let confirm = confirm_prompt.confirm_selected;
                    return Ok(self.resolve_confirm_kill_running(confirm));
                }
                _ => {}
            }
            return Ok(false);
        }

        if let PromptState::ConfirmDeleteAgent {
            focus,
            delete_worktree,
            worktree_shared,
            ..
        } = &mut self.prompt
        {
            // When the worktree is shared with another session it is always
            // preserved, so the checkbox is hidden and the focus cycle skips
            // over it (Cancel ↔ Delete only).
            let shared = *worktree_shared;
            if is_reverse_tab(key) {
                *focus = match (*focus, shared) {
                    (DeleteAgentFocus::Cancel, false) => DeleteAgentFocus::Checkbox,
                    (DeleteAgentFocus::Delete, false) => DeleteAgentFocus::Cancel,
                    (DeleteAgentFocus::Checkbox, _) => DeleteAgentFocus::Delete,
                    (DeleteAgentFocus::Cancel, true) => DeleteAgentFocus::Delete,
                    (DeleteAgentFocus::Delete, true) => DeleteAgentFocus::Cancel,
                };
                return Ok(false);
            }
            match self.bindings.lookup(&key, BindingScope::Dialog) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::ToggleSelection) => {
                    *focus = match (*focus, shared) {
                        (DeleteAgentFocus::Cancel, false) => DeleteAgentFocus::Delete,
                        (DeleteAgentFocus::Delete, false) => DeleteAgentFocus::Checkbox,
                        (DeleteAgentFocus::Checkbox, _) => DeleteAgentFocus::Cancel,
                        (DeleteAgentFocus::Cancel, true) => DeleteAgentFocus::Delete,
                        (DeleteAgentFocus::Delete, true) => DeleteAgentFocus::Cancel,
                    };
                }
                Some(Action::Confirm) => match *focus {
                    DeleteAgentFocus::Checkbox => {
                        *delete_worktree = !*delete_worktree;
                    }
                    DeleteAgentFocus::Cancel => {
                        return Ok(self.resolve_confirm_delete_agent(false));
                    }
                    DeleteAgentFocus::Delete => {
                        return Ok(self.resolve_confirm_delete_agent(true));
                    }
                },
                // Space activates the focused element — toggles the checkbox
                // when focused there, otherwise activates the current button.
                _ if key.code == KeyCode::Char(' ') => match *focus {
                    DeleteAgentFocus::Checkbox => {
                        *delete_worktree = !*delete_worktree;
                    }
                    DeleteAgentFocus::Cancel => {
                        return Ok(self.resolve_confirm_delete_agent(false));
                    }
                    DeleteAgentFocus::Delete => {
                        return Ok(self.resolve_confirm_delete_agent(true));
                    }
                },
                _ => {}
            }
            return Ok(false);
        }

        if let PromptState::ConfirmDeleteTerminal {
            confirm_selected, ..
        } = &mut self.prompt
        {
            match self.bindings.lookup(&key, BindingScope::Dialog) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::ToggleSelection) => {
                    *confirm_selected = !*confirm_selected;
                }
                Some(Action::Confirm) => {
                    let confirm = *confirm_selected;
                    return Ok(self.resolve_confirm_delete_terminal(confirm));
                }
                _ if key.code == KeyCode::Char(' ') => {
                    let confirm = *confirm_selected;
                    return Ok(self.resolve_confirm_delete_terminal(confirm));
                }
                _ => {}
            }
        }

        if let PromptState::ConfirmQuit {
            confirm_selected, ..
        } = &mut self.prompt
        {
            match self.bindings.lookup(&key, BindingScope::Dialog) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::ToggleSelection) => {
                    *confirm_selected = !*confirm_selected;
                }
                Some(Action::Confirm) => {
                    let confirm = *confirm_selected;
                    return Ok(self.resolve_confirm_quit(confirm));
                }
                _ if key.code == KeyCode::Char(' ') => {
                    let confirm = *confirm_selected;
                    return Ok(self.resolve_confirm_quit(confirm));
                }
                _ => {}
            }
        }

        if let PromptState::ConfirmDiscardFile {
            confirm_selected, ..
        } = &mut self.prompt
        {
            match self.bindings.lookup(&key, BindingScope::Dialog) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::ToggleSelection) => {
                    *confirm_selected = !*confirm_selected;
                }
                Some(Action::Confirm) => {
                    let confirm = *confirm_selected;
                    return Ok(self.resolve_confirm_discard_file(confirm));
                }
                _ if key.code == KeyCode::Char(' ') => {
                    let confirm = *confirm_selected;
                    return Ok(self.resolve_confirm_discard_file(confirm));
                }
                _ => {}
            }
            return Ok(false);
        }

        if let PromptState::ConfirmNonDefaultBranch {
            action,
            focus,
            kind,
            checkout_default,
            ..
        } = &mut self.prompt
        {
            // Checkbox is only reachable in the confident (Known) path.
            // Heuristic warnings and create-agent prompts have no checkbox,
            // so focus cycles Cancel ↔ Add.
            let has_checkbox =
                matches!(kind, BranchWarningKind::Known { .. }) && action.allows_add_anyway();
            match self.bindings.lookup(&key, BindingScope::Dialog) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::ToggleSelection) => {
                    let reverse = is_reverse_tab(key);
                    *focus = match (*focus, has_checkbox, reverse) {
                        (ConfirmNonDefaultBranchFocus::Cancel, true, false) => {
                            ConfirmNonDefaultBranchFocus::Add
                        }
                        (ConfirmNonDefaultBranchFocus::Add, true, false) => {
                            ConfirmNonDefaultBranchFocus::Checkbox
                        }
                        (ConfirmNonDefaultBranchFocus::Checkbox, _, false) => {
                            ConfirmNonDefaultBranchFocus::Cancel
                        }
                        (ConfirmNonDefaultBranchFocus::Cancel, true, true) => {
                            ConfirmNonDefaultBranchFocus::Checkbox
                        }
                        (ConfirmNonDefaultBranchFocus::Add, true, true) => {
                            ConfirmNonDefaultBranchFocus::Cancel
                        }
                        (ConfirmNonDefaultBranchFocus::Checkbox, _, true) => {
                            ConfirmNonDefaultBranchFocus::Add
                        }
                        (ConfirmNonDefaultBranchFocus::Cancel, false, _) => {
                            ConfirmNonDefaultBranchFocus::Add
                        }
                        (ConfirmNonDefaultBranchFocus::Add, false, _) => {
                            ConfirmNonDefaultBranchFocus::Cancel
                        }
                    };
                }
                Some(Action::Confirm) => match *focus {
                    ConfirmNonDefaultBranchFocus::Checkbox => {
                        *checkout_default = !*checkout_default;
                    }
                    ConfirmNonDefaultBranchFocus::Cancel => {
                        self.prompt = PromptState::None;
                    }
                    ConfirmNonDefaultBranchFocus::Add => {
                        return Ok(self.resolve_confirm_non_default_branch());
                    }
                },
                // Space activates the focused element — toggles the checkbox
                // when focused there, otherwise activates the current button.
                _ if key.code == KeyCode::Char(' ') => match *focus {
                    ConfirmNonDefaultBranchFocus::Checkbox => {
                        *checkout_default = !*checkout_default;
                    }
                    ConfirmNonDefaultBranchFocus::Cancel => {
                        self.prompt = PromptState::None;
                    }
                    ConfirmNonDefaultBranchFocus::Add => {
                        return Ok(self.resolve_confirm_non_default_branch());
                    }
                },
                _ => {}
            }
            return Ok(false);
        }

        if let PromptState::ConfirmUseExistingBranch {
            confirm_selected, ..
        } = &mut self.prompt
        {
            match self.bindings.lookup(&key, BindingScope::Dialog) {
                Some(Action::CloseOverlay) => self.prompt = PromptState::None,
                Some(Action::ToggleSelection) => {
                    *confirm_selected = !*confirm_selected;
                }
                Some(Action::Confirm) => {
                    let confirm = *confirm_selected;
                    return Ok(self.resolve_confirm_use_existing_branch(confirm));
                }
                _ if key.code == KeyCode::Char(' ') => {
                    let confirm = *confirm_selected;
                    return Ok(self.resolve_confirm_use_existing_branch(confirm));
                }
                _ => {}
            }
            return Ok(false);
        }

        if matches!(self.prompt, PromptState::EditMacros { .. }) {
            self.handle_edit_macros_key(key)?;
            return Ok(false);
        }

        if matches!(self.prompt, PromptState::PullRequestInput { .. }) {
            let is_plain_char = matches!(key.code, KeyCode::Char(_))
                && !key.modifiers.contains(KeyModifiers::CONTROL);
            let action = if is_plain_char {
                None
            } else {
                self.bindings.lookup(&key, BindingScope::Dialog)
            };

            match action {
                Some(Action::CloseOverlay) => {
                    self.prompt = PromptState::None;
                }
                Some(Action::Confirm) => {
                    let (project, raw_input) = match &self.prompt {
                        PromptState::PullRequestInput { project, input } => {
                            (project.clone(), input.text.trim().to_string())
                        }
                        _ => unreachable!(),
                    };
                    self.dispatch_pull_request_lookup(project, raw_input)?;
                }
                _ => {
                    if let PromptState::PullRequestInput { input, .. } = &mut self.prompt {
                        input.handle_key(key);
                    }
                }
            }
            return Ok(false);
        }

        if matches!(self.prompt, PromptState::NameNewAgent { .. }) {
            let is_plain_char = matches!(key.code, KeyCode::Char(_))
                && !key.modifiers.contains(KeyModifiers::CONTROL);
            let action = if is_plain_char {
                None
            } else {
                self.bindings.lookup(&key, BindingScope::Dialog)
            };

            match action {
                Some(Action::CloseOverlay) => {
                    self.prompt = PromptState::None;
                }
                Some(Action::ToggleSelection) => {
                    self.focus_next_name_new_agent_control(!is_reverse_tab(key));
                }
                Some(Action::Confirm) => {
                    // Extract the name from the input before taking ownership.
                    let name = if let PromptState::NameNewAgent { input, .. } = &self.prompt {
                        input.text.trim().to_string()
                    } else {
                        unreachable!()
                    };
                    if name.is_empty() {
                        self.set_error("Agent name cannot be empty.");
                        self.prompt = PromptState::None;
                        return Ok(false);
                    }
                    if !git::is_valid_agent_name(&name) {
                        self.set_error(
                            "Agent name may only contain letters, digits, dashes, underscores, \
                             or slashes. It cannot start with \"-\" or \"/\", end with \"/\", \
                             or contain \"//\".",
                        );
                        return Ok(false);
                    }
                    // Take ownership of the prompt to extract the request.
                    let old_prompt = std::mem::replace(&mut self.prompt, PromptState::None);
                    let PromptState::NameNewAgent { mut request, .. } = old_prompt else {
                        unreachable!()
                    };

                    // For fresh project agents, check whether the branch already
                    // exists locally or on the remote before creating a new one.
                    // PR-based agents intentionally skip this prompt because the
                    // PR head branch is expected to exist upstream.
                    if let CreateAgentRequest::NewProject { project, .. } = &request {
                        let repo_path = std::path::PathBuf::from(&project.path);
                        if let Some(location) = git::branch_exists(&repo_path, &name) {
                            set_create_agent_request_custom_name(&mut request, name.clone());
                            self.prompt = PromptState::ConfirmUseExistingBranch {
                                request,
                                branch_name: name,
                                location,
                                confirm_selected: false,
                            };
                            return Ok(false);
                        }
                    }

                    let msg = match &request {
                        CreateAgentRequest::NewProject { project, .. } => {
                            format!(
                                "Creating a new agent worktree \"{name}\" for project \"{}\" and launching a fresh session...",
                                project.name
                            )
                        }
                        CreateAgentRequest::PullRequest {
                            project, number, ..
                        } => {
                            format!(
                                "Creating a new agent worktree \"{name}\" from PR #{number} for project \"{}\" and launching a fresh session...",
                                project.name
                            )
                        }
                        CreateAgentRequest::ForkSession { source_label, .. } => {
                            format!(
                                "Forking agent \"{source_label}\" as \"{name}\" by cloning its current worktree contents into a fresh session...",
                            )
                        }
                        CreateAgentRequest::ExistingManagedWorktree {
                            project,
                            worktree_path,
                            ..
                        } => {
                            format!(
                                "Starting agent \"{name}\" in existing worktree {} for project \"{}\"...",
                                worktree_path.display(),
                                project.name
                            )
                        }
                        CreateAgentRequest::ForkExternalWorktree {
                            project,
                            source_label,
                            ..
                        } => {
                            format!(
                                "Copying external worktree \"{source_label}\" into a managed worktree \"{name}\" for project \"{}\"...",
                                project.name
                            )
                        }
                    };
                    set_create_agent_request_custom_name(&mut request, name);
                    self.dispatch_create_agent_request(request, msg)?;
                }
                _ => {
                    if key.code == KeyCode::Char(' ') {
                        let checkbox_focused = matches!(
                            self.prompt,
                            PromptState::NameNewAgent {
                                focus: NameNewAgentFocus::RandomizedNameCheckbox,
                                ..
                            }
                        );
                        if checkbox_focused {
                            self.toggle_focused_name_new_agent_checkbox();
                        } else if let PromptState::NameNewAgent { input, .. } = &mut self.prompt {
                            input.handle_key(key);
                        }
                    } else if let PromptState::NameNewAgent { input, .. } = &mut self.prompt {
                        input.handle_key(key);
                    }
                }
            }
            return Ok(false);
        }

        if let PromptState::RenameSession {
            session_id,
            input,
            rename_branch,
        } = &mut self.prompt
        {
            let is_plain_char = matches!(key.code, KeyCode::Char(_))
                && !key.modifiers.contains(KeyModifiers::CONTROL);
            let action = if is_plain_char {
                None
            } else {
                self.bindings.lookup(&key, BindingScope::Dialog)
            };

            match action {
                Some(Action::CloseOverlay) => {
                    self.prompt = PromptState::None;
                }
                Some(Action::Confirm) => {
                    let id = session_id.clone();
                    let new_name = input.text.clone();
                    let also_rename_branch = *rename_branch;
                    self.prompt = PromptState::None;
                    self.apply_rename_session(&id, new_name, also_rename_branch);
                }
                Some(Action::ToggleSelection) => {
                    *rename_branch = !*rename_branch;
                }
                _ => {
                    input.handle_key(key);
                }
            }
            return Ok(false);
        }

        Ok(false)
    }

    fn handle_edit_macros_key(&mut self, key: KeyEvent) -> Result<bool> {
        if matches!(
            self.prompt,
            PromptState::EditMacros {
                pending_delete: Some(_),
                ..
            }
        ) {
            return self.handle_confirm_delete_macro_key(key);
        }

        let PromptState::EditMacros {
            entries,
            selected,
            editing,
            ..
        } = &mut self.prompt
        else {
            return Ok(false);
        };

        if let Some(edit_state) = editing {
            match edit_state.stage {
                MacroEditStage::EditName => {
                    if key.code == KeyCode::Esc {
                        *editing = None;
                        return Ok(false);
                    }
                    if key.code == KeyCode::Tab {
                        edit_state.surface = edit_state.surface.next();
                        return Ok(false);
                    }
                    if is_reverse_tab(key) {
                        edit_state.surface = edit_state.surface.prev();
                        return Ok(false);
                    }
                    if key.code == KeyCode::Enter && !edit_state.name_input.is_empty() {
                        let name = edit_state.name_input.text.clone();
                        // For new macros, check for duplicate names
                        if edit_state.id.is_none() && entries.iter().any(|(n, _, _)| *n == name) {
                            self.set_warning(format!(
                                "Name \"{name}\" is already in use. Choose another."
                            ));
                            return Ok(false);
                        }
                        edit_state.stage = MacroEditStage::EditText;
                        return Ok(false);
                    }
                    edit_state.name_input.handle_key(key);
                }
                MacroEditStage::EditText => {
                    if key.code == KeyCode::Esc {
                        // Save the macro
                        let name = edit_state.name_input.text.clone();
                        let text = edit_state.text_input.text.clone();
                        let surface = edit_state.surface;
                        let old_id = edit_state.id.clone();

                        if text.is_empty() {
                            // Empty text — don't save
                            *editing = None;
                            return Ok(false);
                        }

                        // If renaming, remove the old entry
                        if let Some(ref old_name) = old_id
                            && *old_name != name
                        {
                            self.config.macros.entries.shift_remove(old_name);
                        }

                        // Update config
                        self.config.macros.entries.insert(
                            name.clone(),
                            crate::config::MacroEntry {
                                text: text.clone(),
                                surface,
                            },
                        );

                        // Update the entries snapshot in PromptState
                        let PromptState::EditMacros {
                            entries, editing, ..
                        } = &mut self.prompt
                        else {
                            return Ok(false);
                        };
                        *editing = None;

                        // Update entries list
                        if let Some(old_name) = old_id {
                            if let Some(existing) =
                                entries.iter_mut().find(|(n, _, _)| *n == old_name)
                            {
                                existing.0 = name.clone();
                                existing.1 = text;
                                existing.2 = surface;
                            } else {
                                entries.push((name.clone(), text, surface));
                            }
                        } else {
                            entries.push((name.clone(), text, surface));
                        }
                        entries.sort_by(|(a, _, _), (b, _, _)| a.cmp(b));

                        // Persist
                        if let Err(err) = crate::config::save_config(
                            &self.paths.config_path,
                            &self.config,
                            &self.bindings,
                        ) {
                            self.set_error(format!(
                                "Couldn't save macro \"{name}\" to config: {err:#}"
                            ));
                        } else {
                            self.set_info(format!("Macro \"{name}\" saved."));
                        }
                        return Ok(false);
                    }
                    // In multiline mode, TextInput handles Enter/Up/Down
                    edit_state.text_input.handle_key(key);
                }
            }
            return Ok(false);
        }

        // List view — no active editing
        match key.code {
            KeyCode::Esc => {
                self.prompt = PromptState::None;
            }
            KeyCode::Char('j') | KeyCode::Down
                if !entries.is_empty() && *selected + 1 < entries.len() =>
            {
                *selected += 1;
            }
            KeyCode::Char('k') | KeyCode::Up if *selected > 0 => {
                *selected -= 1;
            }
            KeyCode::Enter => {
                // Edit selected macro
                if let Some((name, text, surface)) = entries.get(*selected) {
                    let name = name.clone();
                    let text = text.clone();
                    let surface = *surface;
                    *editing = Some(MacroEditState {
                        id: Some(name.clone()),
                        name_input: TextInput::with_text(name),
                        text_input: TextInput::with_text(text).with_multiline(8),
                        surface,
                        stage: MacroEditStage::EditName,
                    });
                }
            }
            KeyCode::Char('n') => {
                // New macro
                *editing = Some(MacroEditState {
                    id: None,
                    name_input: TextInput::new(),
                    text_input: TextInput::new().with_multiline(8),
                    surface: MacroSurface::default(),
                    stage: MacroEditStage::EditName,
                });
            }
            KeyCode::Char('d') | KeyCode::Delete => {
                // Stage confirmation for deleting the selected macro.
                if let Some((name, _, _)) = entries.get(*selected) {
                    let name = name.clone();
                    let PromptState::EditMacros { pending_delete, .. } = &mut self.prompt else {
                        return Ok(false);
                    };
                    *pending_delete = Some(PendingMacroDelete {
                        name,
                        confirm_selected: false,
                    });
                }
            }
            _ => {}
        }
        Ok(false)
    }

    fn handle_confirm_delete_macro_key(&mut self, key: KeyEvent) -> Result<bool> {
        let action = self.bindings.lookup(&key, BindingScope::Dialog);
        let is_space = key.code == KeyCode::Char(' ');
        let activate = matches!(action, Some(Action::Confirm)) || is_space;

        let confirm = {
            let PromptState::EditMacros { pending_delete, .. } = &mut self.prompt else {
                return Ok(false);
            };
            let Some(pending) = pending_delete else {
                return Ok(false);
            };

            if matches!(action, Some(Action::CloseOverlay)) {
                *pending_delete = None;
                return Ok(false);
            }
            if matches!(action, Some(Action::ToggleSelection)) {
                pending.confirm_selected = !pending.confirm_selected;
                return Ok(false);
            }
            if !activate {
                return Ok(false);
            }
            pending.confirm_selected
        };

        self.resolve_confirm_delete_macro(confirm);
        Ok(false)
    }

    pub(super) fn resolve_confirm_delete_macro(&mut self, confirm: bool) -> bool {
        let PromptState::EditMacros { pending_delete, .. } = &mut self.prompt else {
            return false;
        };
        let Some(pending) = pending_delete.take() else {
            return false;
        };

        if !confirm {
            return false;
        }

        let name = pending.name;
        self.config.macros.entries.shift_remove(&name);

        let PromptState::EditMacros {
            entries, selected, ..
        } = &mut self.prompt
        else {
            return false;
        };
        entries.retain(|(n, _, _)| n != &name);
        if *selected > 0 && *selected >= entries.len() {
            *selected = entries.len().saturating_sub(1);
        }

        if let Err(err) =
            crate::config::save_config(&self.paths.config_path, &self.config, &self.bindings)
        {
            self.set_error(format!(
                "Couldn't persist deletion of macro \"{name}\" to config: {err:#}"
            ));
        } else {
            self.set_info(format!("Macro \"{name}\" deleted."));
        }
        false
    }

    fn handle_resize_key(&mut self, key: KeyEvent) {
        if let Some(action) = self.bindings.lookup(&key, BindingScope::Resize) {
            if self.focus == FocusPane::Files {
                match action {
                    Action::ResizeShrink => self.set_right_width_pct(self.right_width_pct + 2),
                    Action::ResizeGrow => {
                        self.set_right_width_pct(self.right_width_pct.saturating_sub(2))
                    }
                    _ => {}
                }
            } else {
                match action {
                    Action::ResizeShrink => {
                        self.set_left_width_pct(self.left_width_pct.saturating_sub(2))
                    }
                    Action::ResizeGrow => self.set_left_width_pct(self.left_width_pct + 2),
                    _ => {}
                }
            }
        }
    }

    fn set_left_width_pct(&mut self, left_width_pct: u16) {
        self.left_width_pct = clamp_left_width_pct(left_width_pct, self.right_width_pct);
        self.right_width_pct = clamp_right_width_pct(self.right_width_pct, self.left_width_pct);
    }

    fn set_right_width_pct(&mut self, right_width_pct: u16) {
        self.right_width_pct = clamp_right_width_pct(right_width_pct, self.left_width_pct);
        self.left_width_pct = clamp_left_width_pct(self.left_width_pct, self.right_width_pct);
    }

    fn overlay_row_at(
        rect: Rect,
        offset: usize,
        items: usize,
        column: u16,
        row: u16,
    ) -> Option<usize> {
        if !contains_point(rect, column, row) {
            return None;
        }
        let relative_row = usize::from(row.saturating_sub(rect.y));
        let index = offset.saturating_add(relative_row);
        (index < items).then_some(index)
    }

    fn prompt_mouse_target(&self, column: u16, row: u16) -> Option<PromptMouseTarget> {
        match self.overlay_layout.active {
            OverlayMouseLayout::None | OverlayMouseLayout::Help => None,
            OverlayMouseLayout::Command {
                input,
                list,
                items,
                offset,
                ..
            } => {
                if contains_point(input, column, row) {
                    Some(PromptMouseTarget::CommandInput)
                } else {
                    Self::overlay_row_at(list, offset, items, column, row)
                        .map(PromptMouseTarget::CommandItem)
                }
            }
            OverlayMouseLayout::BrowseProjects {
                input,
                list,
                items,
                offset,
                ..
            } => {
                if input.is_some_and(|rect| contains_point(rect, column, row)) {
                    Some(PromptMouseTarget::BrowseProjectInput)
                } else {
                    Self::overlay_row_at(list, offset, items, column, row)
                        .map(PromptMouseTarget::BrowseProjectItem)
                }
            }
            OverlayMouseLayout::PickEditor {
                list,
                items,
                offset,
                ..
            } => Self::overlay_row_at(list, offset, items, column, row)
                .map(PromptMouseTarget::PickEditorItem),
            OverlayMouseLayout::PickProjectWorktree {
                list,
                items,
                offset,
                ..
            } => Self::overlay_row_at(list, offset, items, column, row)
                .map(PromptMouseTarget::PickProjectWorktreeItem),
            OverlayMouseLayout::ResourceMonitor { .. } => None,
            OverlayMouseLayout::StartupCommandLogs {
                list,
                items,
                offset,
                close_button,
                ..
            } => {
                if contains_point(close_button, column, row) {
                    Some(PromptMouseTarget::StartupCommandLogsClose)
                } else {
                    Self::overlay_row_at(list, offset, items, column, row)
                        .map(PromptMouseTarget::StartupCommandLogItem)
                }
            }
            OverlayMouseLayout::ConfigureStartupCommand { input } => {
                contains_point(input, column, row).then_some(PromptMouseTarget::StartupCommandInput)
            }
            OverlayMouseLayout::ChangeTheme {
                list,
                items,
                offset,
                ..
            } => Self::overlay_row_at(list, offset, items, column, row)
                .map(PromptMouseTarget::ChangeThemeItem),
            OverlayMouseLayout::ChangeAgentProvider {
                list,
                items,
                offset,
                cancel_button,
                apply_button,
            } => {
                if let Some(index) = Self::overlay_row_at(list, offset, items, column, row) {
                    Some(PromptMouseTarget::ChangeAgentProviderItem(index))
                } else if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ChangeAgentProviderCancel)
                } else if contains_point(apply_button, column, row) {
                    Some(PromptMouseTarget::ChangeAgentProviderApply)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ChangeDefaultProvider {
                list,
                items,
                offset,
                cancel_button,
                apply_button,
            } => {
                if let Some(index) = Self::overlay_row_at(list, offset, items, column, row) {
                    Some(PromptMouseTarget::ChangeDefaultProviderItem(index))
                } else if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ChangeDefaultProviderCancel)
                } else if contains_point(apply_button, column, row) {
                    Some(PromptMouseTarget::ChangeDefaultProviderApply)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ChangeProjectDefaultProvider {
                list,
                items,
                offset,
                cancel_button,
                apply_button,
            } => {
                if let Some(index) = Self::overlay_row_at(list, offset, items, column, row) {
                    Some(PromptMouseTarget::ChangeProjectDefaultProviderItem(index))
                } else if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ChangeProjectDefaultProviderCancel)
                } else if contains_point(apply_button, column, row) {
                    Some(PromptMouseTarget::ChangeProjectDefaultProviderApply)
                } else {
                    None
                }
            }
            OverlayMouseLayout::KillRunning {
                input,
                list,
                items,
                offset,
                cancel_button,
                hovered_button,
                selected_button,
                visible_button,
            } => {
                if input.is_some_and(|rect| contains_point(rect, column, row)) {
                    Some(PromptMouseTarget::RuntimeKillInput)
                } else if let Some(index) = Self::overlay_row_at(list, offset, items, column, row) {
                    Some(PromptMouseTarget::RuntimeKillItem(index))
                } else if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::RuntimeKillCancel)
                } else if contains_point(hovered_button, column, row) {
                    Some(PromptMouseTarget::RuntimeKillHovered)
                } else if contains_point(selected_button, column, row) {
                    Some(PromptMouseTarget::RuntimeKillSelected)
                } else if contains_point(visible_button, column, row) {
                    Some(PromptMouseTarget::RuntimeKillVisible)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfirmKillRunning {
                cancel_button,
                kill_button,
            } => {
                if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ConfirmKillCancel)
                } else if contains_point(kill_button, column, row) {
                    Some(PromptMouseTarget::ConfirmKillConfirm)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfirmDeleteAgent {
                cancel_button,
                delete_button,
                checkbox,
            } => {
                if checkbox.is_some_and(|checkbox| contains_point(checkbox.rect, column, row)) {
                    checkbox.map(|checkbox| PromptMouseTarget::Checkbox(checkbox.id))
                } else if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ConfirmDeleteCancel)
                } else if contains_point(delete_button, column, row) {
                    Some(PromptMouseTarget::ConfirmDeleteConfirm)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfirmDeleteTerminal {
                cancel_button,
                delete_button,
            } => {
                if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ConfirmDeleteTerminalCancel)
                } else if contains_point(delete_button, column, row) {
                    Some(PromptMouseTarget::ConfirmDeleteTerminalConfirm)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfirmDeleteMacro {
                cancel_button,
                delete_button,
            } => {
                if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ConfirmDeleteMacroCancel)
                } else if contains_point(delete_button, column, row) {
                    Some(PromptMouseTarget::ConfirmDeleteMacroConfirm)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfirmQuit {
                cancel_button,
                quit_button,
            } => {
                if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ConfirmQuitCancel)
                } else if contains_point(quit_button, column, row) {
                    Some(PromptMouseTarget::ConfirmQuitConfirm)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfirmDiscardFile {
                cancel_button,
                discard_button,
            } => {
                if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ConfirmDiscardCancel)
                } else if contains_point(discard_button, column, row) {
                    Some(PromptMouseTarget::ConfirmDiscardConfirm)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfirmNonDefaultBranch {
                cancel_button,
                add_button,
                checkbox,
            } => {
                if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ConfirmNonDefaultBranchCancel)
                } else if contains_point(add_button, column, row) {
                    Some(PromptMouseTarget::ConfirmNonDefaultBranchAdd)
                } else if checkbox
                    .is_some_and(|checkbox| contains_point(checkbox.rect, column, row))
                {
                    checkbox.map(|checkbox| PromptMouseTarget::Checkbox(checkbox.id))
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfirmUseExistingBranch {
                cancel_button,
                use_button,
            } => {
                if contains_point(cancel_button, column, row) {
                    Some(PromptMouseTarget::ConfirmUseExistingBranchCancel)
                } else if contains_point(use_button, column, row) {
                    Some(PromptMouseTarget::ConfirmUseExistingBranchUse)
                } else {
                    None
                }
            }
            OverlayMouseLayout::ConfigReloadFailed {
                close_button,
                apply_button,
                checkbox,
            } => {
                if contains_point(checkbox.rect, column, row) {
                    Some(PromptMouseTarget::Checkbox(checkbox.id))
                } else if contains_point(close_button, column, row) {
                    Some(PromptMouseTarget::ConfigReloadFailedClose)
                } else if contains_point(apply_button, column, row) {
                    Some(PromptMouseTarget::ConfigReloadFailedApply)
                } else {
                    None
                }
            }
            OverlayMouseLayout::AddProjectFailed { ok_button } => {
                if contains_point(ok_button, column, row) {
                    Some(PromptMouseTarget::AddProjectFailedOk)
                } else {
                    None
                }
            }
            OverlayMouseLayout::RenameSession { input, checkbox } => {
                if checkbox.is_some_and(|checkbox| contains_point(checkbox.rect, column, row)) {
                    checkbox.map(|checkbox| PromptMouseTarget::Checkbox(checkbox.id))
                } else {
                    contains_point(input, column, row).then_some(PromptMouseTarget::RenameInput)
                }
            }
            OverlayMouseLayout::NameNewAgent { input, checkbox } => {
                if checkbox.is_some_and(|checkbox| contains_point(checkbox.rect, column, row)) {
                    checkbox.map(|checkbox| PromptMouseTarget::Checkbox(checkbox.id))
                } else {
                    contains_point(input, column, row)
                        .then_some(PromptMouseTarget::NameNewAgentInput)
                }
            }
        }
    }

    fn mouse_target(&self, column: u16, row: u16) -> Option<MouseTarget> {
        if !matches!(self.fullscreen_overlay, FullscreenOverlay::None) {
            return self
                .mouse_layout
                .agent_term
                .filter(|rect| contains_point(*rect, column, row))
                .map(|_| MouseTarget::Center);
        }

        if contains_point(self.mouse_layout.left_list, column, row) {
            if self.left_items().is_empty() {
                return Some(MouseTarget::LeftPane);
            }
            let index = usize::from(row.saturating_sub(self.mouse_layout.left_list.y));
            if index < self.left_items().len() {
                if !self.is_selectable_left_item(index) {
                    return Some(MouseTarget::LeftPane);
                }
                return Some(MouseTarget::LeftRow(index));
            }
            return Some(MouseTarget::LeftPane);
        }

        {
            let tl = self.mouse_layout.terminal_list;
            if tl.width > 0 && tl.height > 0 && contains_point(tl, column, row) {
                let index = usize::from(row.saturating_sub(tl.y));
                let term_count = self.terminal_items().len();
                if index < term_count {
                    return Some(MouseTarget::TerminalRow(index));
                }
                return Some(MouseTarget::TerminalPane);
            }
        }

        if contains_point(self.mouse_layout.left, column, row) {
            return Some(MouseTarget::LeftPane);
        }

        if let Some(area) = self.mouse_layout.unstaged_list
            && contains_point(area, column, row)
        {
            let index = usize::from(row.saturating_sub(area.y));
            let file_index = (index < self.unstaged_files.len()).then_some(index);
            return Some(MouseTarget::UnstagedFile(file_index));
        }

        if let Some(area) = self.mouse_layout.staged_list
            && contains_point(area, column, row)
        {
            let index = usize::from(row.saturating_sub(area.y));
            let file_index = (index < self.staged_files.len()).then_some(index);
            return Some(MouseTarget::StagedFile(file_index));
        }

        if let Some(area) = self.mouse_layout.commit_area
            && contains_point(area, column, row)
        {
            if self
                .mouse_layout
                .commit_text_area
                .is_some_and(|text_area| contains_point(text_area, column, row))
            {
                return Some(MouseTarget::CommitText);
            }
            return Some(MouseTarget::CommitChrome);
        }

        if contains_point(self.mouse_layout.right, column, row) {
            return Some(MouseTarget::FilesPane);
        }

        if contains_point(self.mouse_layout.center, column, row) {
            return Some(MouseTarget::Center);
        }

        None
    }

    fn resize_drag_at_mouse(&self, column: u16, row: u16) -> Option<ResizeDragState> {
        let body = self.mouse_layout.body;
        if !contains_point(body, column, row) {
            return None;
        }

        let left_edge = self.mouse_layout.left.x + self.mouse_layout.left.width.saturating_sub(1);
        let center_left = self.mouse_layout.center.x;
        let center_right =
            self.mouse_layout.center.x + self.mouse_layout.center.width.saturating_sub(1);
        let right_left = self.mouse_layout.right.x;

        if !self.left_collapsed && (column == left_edge || column == center_left) {
            return Some(ResizeDragState::LeftDivider);
        }

        if !self.right_hidden && (column == center_right || column == right_left) {
            return Some(ResizeDragState::RightDivider);
        }

        // Horizontal divider between Projects and Terminals sections.
        let tl = self.mouse_layout.terminal_list;
        if tl.width > 0 && tl.height > 0 {
            let left = self.mouse_layout.left;
            let divider_row = tl.y.saturating_sub(1);
            if row == divider_row && column >= left.x && column < left.x + left.width {
                return Some(ResizeDragState::TerminalDivider);
            }
        }

        // Horizontal dividers inside the right pane.
        //
        // The inner content rects (unstaged_list, staged_list) exclude the
        // surrounding block borders, so the gap between two adjacent rects is
        // typically only 1-2 rows of border chrome.  We extend the hit zone
        // outward from each content rect by 1 row to cover the border that
        // belongs to each block, making the target at least 3 rows wide
        // (bottom border + gap + top border) without overlapping the content.

        // Between Unstaged and Staged.
        if let (Some(unstaged), Some(staged)) = (
            self.mouse_layout.unstaged_list,
            self.mouse_layout.staged_list,
        ) {
            let right = self.mouse_layout.right;
            // The content rects don't include their enclosing block borders.
            // Extend one row past each content edge to cover the border row.
            let hit_top = unstaged.y + unstaged.height; // first row after unstaged content (border)
            let hit_bottom = staged.y.saturating_sub(1); // last row before staged content (border)
            if row >= hit_top
                && row <= hit_bottom
                && column >= right.x
                && column < right.x + right.width
            {
                return Some(ResizeDragState::StagedDivider);
            }
        }

        // Between Staged Changes and Commit Message.
        // staged_list is an inner rect; commit_area is an outer rect (includes border).
        if let (Some(staged), Some(commit)) =
            (self.mouse_layout.staged_list, self.mouse_layout.commit_area)
        {
            let right = self.mouse_layout.right;
            let hit_top = staged.y + staged.height; // first row after staged content (border)
            let hit_bottom = commit.y; // top border row of commit block
            if row >= hit_top
                && row <= hit_bottom
                && column >= right.x
                && column < right.x + right.width
            {
                return Some(ResizeDragState::CommitDivider);
            }
        }

        None
    }

    fn set_left_selection(&mut self, index: usize) {
        if !self.is_selectable_left_item(index) {
            return;
        }
        self.focus = FocusPane::Left;
        self.input_target = InputTarget::None;
        self.fullscreen_overlay = FullscreenOverlay::None;
        if self.selected_left != index {
            self.selected_left = index;
            self.close_diff_view();
            self.reload_changed_files();
        }
    }

    fn register_mouse_click(
        &mut self,
        target: MouseClickTarget,
        item_index: Option<usize>,
    ) -> bool {
        let now = Instant::now();
        if let Some(last) = self.last_mouse_click
            && last.target == target
            && last.item_index == item_index
            && now.duration_since(last.at) <= DOUBLE_CLICK_THRESHOLD
        {
            self.last_mouse_click = None;
            return true;
        }

        self.last_mouse_click = Some(RecentMouseClick {
            target,
            item_index,
            at: now,
        });
        false
    }

    fn set_command_palette_selection(&mut self, index: usize) {
        let count = match &self.prompt {
            PromptState::Command { input, .. } => self.filtered_palette_commands(&input.text).len(),
            _ => 0,
        };
        if count == 0 {
            return;
        }
        if let PromptState::Command { selected, .. } = &mut self.prompt {
            *selected = index.min(count.saturating_sub(1));
        }
    }

    fn set_command_palette_cursor_from_mouse(&mut self, column: u16) {
        let input_area = match self.overlay_layout.active {
            OverlayMouseLayout::Command { input, .. } => input,
            _ => return,
        };
        if let PromptState::Command { input, .. } = &mut self.prompt {
            let prefix_width = 2; // "> "
            input.cursor =
                cursor_from_single_line_position(&input.text, input_area, prefix_width, column);
        }
    }

    fn execute_selected_command_palette(&mut self) {
        let command = if let PromptState::Command {
            input, selected, ..
        } = &self.prompt
        {
            if let Some(binding) = self.filtered_palette_commands(&input.text).get(*selected) {
                binding.palette_name.unwrap().to_string()
            } else {
                input.text.trim().to_string()
            }
        } else {
            String::new()
        };
        self.prompt = PromptState::None;
        if let Err(e) = self.execute_command(command) {
            self.set_error(format!("{e:#}"));
        }
    }

    fn visible_browser_entries(&self) -> Vec<BrowserEntry> {
        if let PromptState::BrowseProjects {
            entries, filter, ..
        } = &self.prompt
        {
            if filter.is_empty() {
                entries.clone()
            } else {
                let needle = filter.text.to_lowercase();
                entries
                    .iter()
                    .filter(|entry| entry.label.to_lowercase().contains(&needle))
                    .cloned()
                    .collect()
            }
        } else {
            Vec::new()
        }
    }

    fn set_browser_selection(&mut self, index: usize) {
        let count = self.visible_browser_entries().len();
        if count == 0 {
            return;
        }
        if let PromptState::BrowseProjects { selected, .. } = &mut self.prompt {
            *selected = index.min(count.saturating_sub(1));
        }
    }

    fn set_browser_input_cursor_from_mouse(&mut self, column: u16) {
        let input_area = match self.overlay_layout.active {
            OverlayMouseLayout::BrowseProjects {
                input: Some(input), ..
            } => input,
            _ => return,
        };
        if let PromptState::BrowseProjects {
            filter,
            searching,
            editing_path,
            path_input,
            ..
        } = &mut self.prompt
        {
            if *editing_path {
                path_input.cursor =
                    cursor_from_single_line_position(&path_input.text, input_area, 4, column);
            } else {
                filter.cursor =
                    cursor_from_single_line_position(&filter.text, input_area, 2, column);
                *searching = true;
            }
        }
    }

    fn set_kill_running_hovered(&mut self, visible_index: usize) {
        if let PromptState::KillRunning(prompt) = &mut self.prompt {
            let count = Self::visible_kill_running_indices(prompt).len();
            if count == 0 {
                prompt.hovered_visible_index = 0;
                return;
            }
            prompt.hovered_visible_index = visible_index.min(count.saturating_sub(1));
            prompt.focus = KillRunningFocus::List;
        }
    }

    fn set_kill_running_search_cursor_from_mouse(&mut self, column: u16) {
        let input_area = match self.overlay_layout.active {
            OverlayMouseLayout::KillRunning {
                input: Some(input), ..
            } => input,
            _ => return,
        };
        if let PromptState::KillRunning(prompt) = &mut self.prompt {
            prompt.filter.cursor =
                cursor_from_single_line_position(&prompt.filter.text, input_area, 2, column);
            prompt.searching = true;
            prompt.focus = KillRunningFocus::List;
        }
    }

    fn toggle_hovered_kill_running_selection(&mut self) {
        let target_id = match &self.prompt {
            PromptState::KillRunning(prompt) => {
                let visible = Self::visible_kill_running_indices(prompt);
                visible
                    .get(prompt.hovered_visible_index)
                    .and_then(|&index| prompt.runtimes.get(index))
                    .map(|runtime| runtime.id.clone())
            }
            _ => None,
        };

        let Some(target_id) = target_id else {
            self.set_error(
                "No running agent or terminal is highlighted. Move to a visible row first.",
            );
            return;
        };

        if let PromptState::KillRunning(prompt) = &mut self.prompt
            && !prompt.selected_ids.insert(target_id.clone())
        {
            prompt.selected_ids.remove(&target_id);
        }
    }

    fn execute_kill_running_footer_action(
        &mut self,
        action: KillRunningFooterAction,
    ) -> Result<()> {
        let enabled = match &self.prompt {
            PromptState::KillRunning(prompt) => Self::kill_running_footer_enabled(prompt, action),
            _ => true,
        };
        if !enabled {
            if matches!(action, KillRunningFooterAction::Selected) {
                self.set_info(
                    "Select one or more runtimes before using Kill Selected. Press Space to mark the highlighted row.",
                );
            }
            return Ok(());
        }
        match action {
            KillRunningFooterAction::Cancel => {
                self.prompt = PromptState::None;
                self.set_info("Closed Kill Running. No agents or terminals were killed.");
            }
            _ => {
                if let Some(action) = action.action() {
                    self.open_confirm_kill_running_action(action)?;
                }
            }
        }
        Ok(())
    }

    pub(crate) fn kill_running_footer_enabled(
        prompt: &KillRunningPrompt,
        action: KillRunningFooterAction,
    ) -> bool {
        match action {
            KillRunningFooterAction::Cancel => true,
            KillRunningFooterAction::Selected => !prompt.selected_ids.is_empty(),
            KillRunningFooterAction::Hovered | KillRunningFooterAction::Visible => {
                !Self::visible_kill_running_indices(prompt).is_empty()
            }
        }
    }

    fn next_kill_running_footer_action(
        prompt: &KillRunningPrompt,
        current: Option<KillRunningFooterAction>,
        forward: bool,
    ) -> KillRunningFocus {
        const ACTIONS: [KillRunningFooterAction; 4] = [
            KillRunningFooterAction::Cancel,
            KillRunningFooterAction::Hovered,
            KillRunningFooterAction::Selected,
            KillRunningFooterAction::Visible,
        ];

        let enabled: Vec<KillRunningFooterAction> = ACTIONS
            .into_iter()
            .filter(|action| Self::kill_running_footer_enabled(prompt, *action))
            .collect();

        if enabled.is_empty() {
            return KillRunningFocus::List;
        }

        match current {
            None => {
                if forward {
                    KillRunningFocus::Footer(enabled[0])
                } else {
                    KillRunningFocus::Footer(*enabled.last().unwrap())
                }
            }
            Some(current) => {
                let Some(index) = enabled.iter().position(|action| *action == current) else {
                    return if forward {
                        KillRunningFocus::Footer(enabled[0])
                    } else {
                        KillRunningFocus::Footer(*enabled.last().unwrap())
                    };
                };
                if forward {
                    if index + 1 < enabled.len() {
                        KillRunningFocus::Footer(enabled[index + 1])
                    } else {
                        KillRunningFocus::List
                    }
                } else if index > 0 {
                    KillRunningFocus::Footer(enabled[index - 1])
                } else {
                    KillRunningFocus::List
                }
            }
        }
    }

    fn open_selected_browser_entry(&mut self) {
        let visible = self.visible_browser_entries();
        let mut browse_to: Option<PathBuf> = None;
        if let PromptState::BrowseProjects {
            current_dir,
            entries,
            loading,
            selected,
            filter,
            ..
        } = &mut self.prompt
            && let Some(entry) = visible.get(*selected)
        {
            let new_dir = entry.path.clone();
            *current_dir = new_dir.clone();
            entries.clear();
            *loading = true;
            *selected = 0;
            filter.clear();
            browse_to = Some(new_dir);
        }
        if let Some(dir) = browse_to {
            self.spawn_browser_entries(&dir);
        }
    }

    fn path_editor_completion_candidates(input: &str) -> Vec<String> {
        let input_path = PathBuf::from(input);
        let (search_dir, prefix) = if input_path.is_dir() && input.ends_with('/') {
            (input_path, String::new())
        } else {
            let parent = input_path
                .parent()
                .unwrap_or_else(|| std::path::Path::new("/"));
            let file_name = input_path
                .file_name()
                .map(|f| f.to_string_lossy().to_string())
                .unwrap_or_default();
            (parent.to_path_buf(), file_name)
        };

        let Ok(read) = std::fs::read_dir(search_dir.clone()) else {
            return Vec::new();
        };
        let prefix_lower = prefix.to_lowercase();
        let mut candidates: Vec<String> = read
            .filter_map(|entry| entry.ok())
            .filter(|entry| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
            .filter(|entry| {
                let name = entry.file_name().to_string_lossy().to_lowercase();
                !name.starts_with('.') && name.starts_with(&prefix_lower)
            })
            .map(|entry| {
                let mut full = search_dir
                    .join(entry.file_name())
                    .to_string_lossy()
                    .to_string();
                full.push('/');
                full
            })
            .collect();
        candidates.sort();
        candidates
    }

    fn refresh_path_editor_completions(&mut self) {
        let input = match &self.prompt {
            PromptState::BrowseProjects {
                editing_path: true,
                path_input,
                ..
            } => path_input.text.clone(),
            _ => return,
        };
        let candidates = Self::path_editor_completion_candidates(&input);
        if let PromptState::BrowseProjects {
            tab_completions,
            tab_index,
            ..
        } = &mut self.prompt
        {
            *tab_completions = candidates;
            *tab_index = 0;
        }
    }

    fn add_project_from_browser_path(&mut self, path: String) {
        let return_prompt = self.prompt.clone();
        if let Err(message) = self.validate_project_add_path(&path) {
            self.open_add_project_failed_modal(message, return_prompt);
            return;
        }
        self.prompt = PromptState::None;
        if let Err(error) = self.add_project(path, String::new()) {
            self.open_add_project_failed_modal(format!("{error:#}"), return_prompt);
        }
    }

    fn open_add_project_failed_modal(&mut self, message: String, return_prompt: PromptState) {
        self.prompt = PromptState::AddProjectFailed {
            message,
            return_prompt: Box::new(return_prompt),
        };
    }

    fn resolve_add_project_failed(&mut self) -> bool {
        let return_prompt = match std::mem::replace(&mut self.prompt, PromptState::None) {
            PromptState::AddProjectFailed { return_prompt, .. } => *return_prompt,
            other => {
                self.prompt = other;
                return false;
            }
        };
        self.prompt = return_prompt;
        false
    }

    fn set_pick_editor_selection(&mut self, index: usize) {
        let count = match &self.prompt {
            PromptState::PickEditor { editors, .. } => editors.len(),
            _ => 0,
        };
        if count == 0 {
            return;
        }
        if let PromptState::PickEditor { selected, .. } = &mut self.prompt {
            *selected = index.min(count.saturating_sub(1));
        }
    }

    fn open_selected_pick_editor(&mut self) {
        let (editor, worktree, label) = if let PromptState::PickEditor {
            session_label,
            worktree_path,
            editors,
            selected,
        } = &self.prompt
        {
            (
                editors.get(*selected).cloned(),
                worktree_path.clone(),
                session_label.clone(),
            )
        } else {
            return;
        };
        self.prompt = PromptState::None;
        if let Some(editor) = editor
            && let Err(e) = self.open_worktree_in_editor(&worktree, &label, &editor)
        {
            self.set_error(format!("{e:#}"));
        }
    }

    fn set_project_worktree_selection_from_visual_row(&mut self, visual_index: usize) {
        let entry_index = match &self.prompt {
            PromptState::PickProjectWorktree(prompt) => {
                let rows = project_worktree_visual_rows(
                    &prompt.entries,
                    prompt.loading,
                    prompt.error.as_deref(),
                );
                rows.get(visual_index).and_then(|row| match row {
                    ProjectWorktreeVisualRow::Entry(index)
                        if prompt
                            .entries
                            .get(*index)
                            .is_some_and(|entry| entry.is_selectable) =>
                    {
                        Some(*index)
                    }
                    _ => None,
                })
            }
            _ => None,
        };
        let Some(entry_index) = entry_index else {
            return;
        };
        if let PromptState::PickProjectWorktree(prompt) = &mut self.prompt {
            prompt.selected = Some(entry_index);
        }
    }

    fn open_selected_project_worktree_agent_prompt(&mut self) -> Result<()> {
        let (project, entry) = match &self.prompt {
            PromptState::PickProjectWorktree(prompt) => {
                let Some(selected) = prompt.selected else {
                    self.set_error("No available worktree is selected.");
                    return Ok(());
                };
                let Some(entry) = prompt.entries.get(selected).cloned() else {
                    self.set_error("No available worktree is selected.");
                    return Ok(());
                };
                if !entry.is_selectable {
                    self.set_error("That worktree already has an agent.");
                    return Ok(());
                }
                (prompt.project.clone(), entry)
            }
            _ => return Ok(()),
        };

        let request = if entry.is_external {
            CreateAgentRequest::ForkExternalWorktree {
                project,
                source_worktree_path: entry.path.clone(),
                source_label: entry.display_name(),
                source_branch: entry.branch_name.clone(),
                custom_name: Some(entry.display_name()),
            }
        } else {
            let managed_root = self.paths.worktrees_root.join(&project.name);
            let default_name = entry
                .path
                .strip_prefix(&managed_root)
                .ok()
                .map(|relative| relative.to_string_lossy().to_string())
                .filter(|name| !name.is_empty())
                .unwrap_or_else(|| entry.display_name());
            CreateAgentRequest::ExistingManagedWorktree {
                project,
                worktree_path: entry.path.clone(),
                branch_name: entry.branch_name.clone(),
                custom_name: Some(default_name),
            }
        };
        self.open_name_new_agent_prompt_for_request(request)
    }

    fn next_change_agent_provider_focus(
        current: ChangeAgentProviderFocus,
        forward: bool,
    ) -> ChangeAgentProviderFocus {
        match (current, forward) {
            (ChangeAgentProviderFocus::List, true) => ChangeAgentProviderFocus::Cancel,
            (ChangeAgentProviderFocus::Cancel, true) => ChangeAgentProviderFocus::Apply,
            (ChangeAgentProviderFocus::Apply, true) => ChangeAgentProviderFocus::List,
            (ChangeAgentProviderFocus::List, false) => ChangeAgentProviderFocus::Apply,
            (ChangeAgentProviderFocus::Apply, false) => ChangeAgentProviderFocus::Cancel,
            (ChangeAgentProviderFocus::Cancel, false) => ChangeAgentProviderFocus::List,
        }
    }

    fn set_change_agent_provider_selection(&mut self, index: usize) {
        let count = match &self.prompt {
            PromptState::ChangeAgentProvider(prompt) => prompt.options.len(),
            _ => 0,
        };
        if count == 0 {
            return;
        }
        if let PromptState::ChangeAgentProvider(prompt) = &mut self.prompt {
            prompt.selected = index.min(count.saturating_sub(1));
            prompt.focus = ChangeAgentProviderFocus::List;
        }
    }

    fn next_change_default_provider_focus(
        current: ChangeDefaultProviderFocus,
        forward: bool,
    ) -> ChangeDefaultProviderFocus {
        match (current, forward) {
            (ChangeDefaultProviderFocus::List, true) => ChangeDefaultProviderFocus::Cancel,
            (ChangeDefaultProviderFocus::Cancel, true) => ChangeDefaultProviderFocus::Apply,
            (ChangeDefaultProviderFocus::Apply, true) => ChangeDefaultProviderFocus::List,
            (ChangeDefaultProviderFocus::List, false) => ChangeDefaultProviderFocus::Apply,
            (ChangeDefaultProviderFocus::Apply, false) => ChangeDefaultProviderFocus::Cancel,
            (ChangeDefaultProviderFocus::Cancel, false) => ChangeDefaultProviderFocus::List,
        }
    }

    fn set_change_default_provider_selection(&mut self, index: usize) {
        let count = match &self.prompt {
            PromptState::ChangeDefaultProvider(prompt) => prompt.options.len(),
            _ => 0,
        };
        if count == 0 {
            return;
        }
        if let PromptState::ChangeDefaultProvider(prompt) = &mut self.prompt {
            prompt.selected = index.min(count.saturating_sub(1));
            prompt.focus = ChangeDefaultProviderFocus::List;
        }
    }

    fn set_change_project_default_provider_selection(&mut self, index: usize) {
        let count = match &self.prompt {
            PromptState::ChangeProjectDefaultProvider(prompt) => prompt.options.len(),
            _ => 0,
        };
        if count == 0 {
            return;
        }
        if let PromptState::ChangeProjectDefaultProvider(prompt) = &mut self.prompt {
            prompt.selected = index.min(count.saturating_sub(1));
            prompt.focus = ChangeDefaultProviderFocus::List;
        }
    }

    fn resolve_confirm_delete_agent(&mut self, confirm: bool) -> bool {
        let (session_id, delete_worktree) = match &self.prompt {
            PromptState::ConfirmDeleteAgent {
                session_id,
                delete_worktree,
                ..
            } => (session_id.clone(), *delete_worktree),
            _ => return false,
        };
        self.prompt = PromptState::None;
        if confirm {
            // Dispatches git work to a worker when needed, so the UI stays
            // responsive. Errors arrive asynchronously via
            // `WorkerEvent::WorktreeRemoveCompleted`.
            self.begin_delete_session(&session_id, delete_worktree);
        }
        false
    }

    fn resolve_confirm_delete_terminal(&mut self, confirm: bool) -> bool {
        let terminal_id = match &self.prompt {
            PromptState::ConfirmDeleteTerminal { terminal_id, .. } => terminal_id.clone(),
            _ => return false,
        };
        self.prompt = PromptState::None;
        if confirm {
            self.do_delete_terminal(&terminal_id);
        }
        false
    }

    fn resolve_confirm_kill_running(&mut self, confirm: bool) -> bool {
        let confirm_prompt = match &self.prompt {
            PromptState::ConfirmKillRunning(confirm_prompt) => confirm_prompt.clone(),
            _ => return false,
        };
        if !confirm {
            self.prompt = PromptState::KillRunning(confirm_prompt.previous);
            self.set_info(
                "Kill cancelled. Your running agents and companion terminals are unchanged.",
            );
            return false;
        }

        self.prompt = PromptState::None;
        let requested = confirm_prompt.target_ids.len();
        let (agents, terminals) = self.kill_runtime_targets(&confirm_prompt.target_ids);
        let killed = agents + terminals;
        let already_gone = requested.saturating_sub(killed);
        if killed == 0 {
            self.set_warning(
                "The selected agents or terminals were already gone, so there was nothing left to kill. Refresh Kill Running if you want to review the current runtime list.",
            );
            return false;
        }

        let mut pieces = Vec::new();
        if agents > 0 {
            pieces.push(format!(
                "{agents} agent{}",
                if agents == 1 { "" } else { "s" }
            ));
        }
        if terminals > 0 {
            pieces.push(format!(
                "{terminals} terminal{}",
                if terminals == 1 { "" } else { "s" }
            ));
        }
        if already_gone > 0 {
            self.set_warning(format!(
                "Killed {}. {} selected runtime{} were already gone. In-progress CLI work was stopped, but the worktree files are still available for review or relaunch.",
                pieces.join(" and "),
                already_gone,
                if already_gone == 1 { "" } else { "s" }
            ));
        } else {
            self.set_info(format!(
                "Killed {}. In-progress CLI work was stopped, but the worktree files are still available for review or relaunch.",
                pieces.join(" and ")
            ));
        }
        false
    }

    fn resolve_confirm_quit(&mut self, confirm: bool) -> bool {
        if matches!(self.prompt, PromptState::ConfirmQuit { .. }) {
            self.prompt = PromptState::None;
        }
        confirm
    }

    fn resolve_confirm_discard_file(&mut self, confirm: bool) -> bool {
        let (file_path, is_untracked) = match &self.prompt {
            PromptState::ConfirmDiscardFile {
                file_path,
                is_untracked,
                ..
            } => (file_path.clone(), *is_untracked),
            _ => return false,
        };
        self.prompt = PromptState::None;
        if confirm && let Some(session) = self.selected_session() {
            let worktree = PathBuf::from(&session.worktree_path);
            match git::discard_file(&worktree, &file_path, is_untracked) {
                Ok(()) => {
                    self.set_info(format!(
                        "Discarded changes to \"{file_path}\". File restored to last committed state."
                    ));
                    self.reload_changed_files();
                }
                Err(e) => self.set_error(format!("Discard failed: {e}")),
            }
        }
        false
    }

    fn resolve_confirm_non_default_branch(&mut self) -> bool {
        let (action, branch, checkout_default, default_branch) = match &self.prompt {
            PromptState::ConfirmNonDefaultBranch {
                action,
                current_branch,
                kind,
                checkout_default,
                ..
            } => {
                let default_branch = match kind {
                    BranchWarningKind::Known { default_branch } => Some(default_branch.clone()),
                    BranchWarningKind::Heuristic => None,
                };
                (
                    action.clone(),
                    current_branch.clone(),
                    *checkout_default && default_branch.is_some(),
                    default_branch,
                )
            }
            _ => return false,
        };
        self.prompt = PromptState::None;
        if checkout_default {
            // Safe: `checkout_default` is only true when `default_branch` is `Some`.
            let target = default_branch.expect("checkout_default implies known default branch");
            let reason = match action {
                NonDefaultBranchAction::AddProject { .. } => "before adding the project",
                NonDefaultBranchAction::CheckoutProjectDefault { .. } => "for the selected project",
            };
            self.dispatch_non_default_branch_checkout(action, target, reason.to_string());
        } else {
            match action {
                NonDefaultBranchAction::AddProject {
                    path,
                    name,
                    leading_branch,
                } => {
                    if let Err(e) = self.finish_add_project(path, name, branch, leading_branch) {
                        self.set_error(format!("{e:#}"));
                    }
                }
                NonDefaultBranchAction::CheckoutProjectDefault { .. } => {
                    self.set_error("Check out the default branch before retrying.");
                }
            }
        }
        false
    }

    fn resolve_confirm_use_existing_branch(&mut self, confirm: bool) -> bool {
        let old_prompt = std::mem::replace(&mut self.prompt, PromptState::None);
        let PromptState::ConfirmUseExistingBranch {
            mut request,
            branch_name,
            ..
        } = old_prompt
        else {
            return false;
        };
        if !confirm {
            return false;
        }
        if let CreateAgentRequest::NewProject {
            use_existing_branch,
            ..
        }
        | CreateAgentRequest::PullRequest {
            use_existing_branch,
            ..
        } = &mut request
        {
            *use_existing_branch = true;
        }
        let msg = match &request {
            CreateAgentRequest::NewProject { project, .. }
            | CreateAgentRequest::PullRequest { project, .. } => {
                format!(
                    "Attaching to existing branch \"{branch_name}\" for project \"{}\" and launching a fresh session...",
                    project.name
                )
            }
            CreateAgentRequest::ForkSession { .. }
            | CreateAgentRequest::ExistingManagedWorktree { .. }
            | CreateAgentRequest::ForkExternalWorktree { .. } => unreachable!(),
        };
        if let Err(e) = self.dispatch_create_agent_request(request, msg) {
            self.set_error(format!("{e:#}"));
        }
        false
    }

    fn set_rename_cursor_from_mouse(&mut self, column: u16) {
        let input_area = match self.overlay_layout.active {
            OverlayMouseLayout::RenameSession { input, .. } => input,
            _ => return,
        };
        if let PromptState::RenameSession { input, .. } = &mut self.prompt {
            input.cursor = cursor_from_single_line_position(&input.text, input_area, 0, column);
        }
    }

    fn set_name_new_agent_cursor_from_mouse(&mut self, column: u16) {
        let input_area = match self.overlay_layout.active {
            OverlayMouseLayout::NameNewAgent { input, .. } => input,
            _ => return,
        };
        if let PromptState::NameNewAgent { input, focus, .. } = &mut self.prompt {
            input.cursor = cursor_from_single_line_position(&input.text, input_area, 0, column);
            *focus = NameNewAgentFocus::Input;
        }
    }

    fn set_startup_command_cursor_from_mouse(&mut self, column: u16, row: u16) {
        let input_area = match self.overlay_layout.active {
            OverlayMouseLayout::ConfigureStartupCommand { input } => input,
            _ => return,
        };
        if let Some(input) = configure_project_text_input_mut(&mut self.prompt) {
            let display_row = usize::from(row.saturating_sub(input_area.y));
            let display_col = usize::from(column.saturating_sub(input_area.x));
            input.set_cursor_from_display_pos(display_row, display_col);
        }
    }

    fn focus_next_name_new_agent_control(&mut self, forward: bool) {
        if let PromptState::NameNewAgent { focus, .. } = &mut self.prompt {
            *focus = match (*focus, forward) {
                (NameNewAgentFocus::Input, true) => NameNewAgentFocus::RandomizedNameCheckbox,
                (NameNewAgentFocus::RandomizedNameCheckbox, true) => NameNewAgentFocus::Input,
                (NameNewAgentFocus::Input, false) => NameNewAgentFocus::RandomizedNameCheckbox,
                (NameNewAgentFocus::RandomizedNameCheckbox, false) => NameNewAgentFocus::Input,
            };
        }
    }

    fn toggle_focused_name_new_agent_checkbox(&mut self) {
        let focus = match &self.prompt {
            PromptState::NameNewAgent { focus, .. } => *focus,
            _ => return,
        };
        match focus {
            NameNewAgentFocus::RandomizedNameCheckbox => {
                self.toggle_name_new_agent_randomized_name()
            }
            NameNewAgentFocus::Input => {}
        }
    }

    fn toggle_name_new_agent_randomized_name(&mut self) {
        if let PromptState::NameNewAgent {
            input,
            randomize_name,
            randomized_name,
            focus,
            ..
        } = &mut self.prompt
        {
            *focus = NameNewAgentFocus::RandomizedNameCheckbox;
            *randomize_name = !*randomize_name;
            if *randomize_name {
                let name = crate::git::docker_style_name();
                input.set_text(name.clone());
                *randomized_name = Some(name);
            } else if randomized_name.as_deref() == Some(input.text.as_str()) {
                input.clear();
                *randomized_name = None;
            } else {
                *randomized_name = None;
            }
        }
    }

    fn toggle_overlay_checkbox(&mut self, checkbox_id: OverlayCheckboxId) {
        match checkbox_id {
            OverlayCheckboxId::DeleteAgentWorktree => {
                if let PromptState::ConfirmDeleteAgent {
                    delete_worktree,
                    focus,
                    worktree_shared,
                    ..
                } = &mut self.prompt
                    && !*worktree_shared
                {
                    *delete_worktree = !*delete_worktree;
                    *focus = DeleteAgentFocus::Checkbox;
                }
            }
            OverlayCheckboxId::RenameSessionBranch => {
                if let PromptState::RenameSession { rename_branch, .. } = &mut self.prompt {
                    *rename_branch = !*rename_branch;
                }
            }
            OverlayCheckboxId::NonDefaultBranchCheckoutDefault => {
                if let PromptState::ConfirmNonDefaultBranch {
                    action,
                    kind,
                    checkout_default,
                    focus,
                    ..
                } = &mut self.prompt
                    && matches!(kind, BranchWarningKind::Known { .. })
                    && action.allows_add_anyway()
                {
                    *checkout_default = !*checkout_default;
                    *focus = ConfirmNonDefaultBranchFocus::Checkbox;
                }
            }
            OverlayCheckboxId::NameNewAgentRandomizedPetName => {
                self.toggle_name_new_agent_randomized_name();
            }
            OverlayCheckboxId::ConfigReloadRecoverOldConfig => {
                if let PromptState::ConfigReloadFailed {
                    recover_old_config,
                    focus,
                    ..
                } = &mut self.prompt
                {
                    *recover_old_config = !*recover_old_config;
                    *focus = ConfigReloadFailedFocus::Checkbox;
                }
            }
        }
    }

    fn handle_prompt_mouse(&mut self, mouse: MouseEvent) -> bool {
        if let PromptState::ResourceMonitor {
            scroll_offset,
            selected_row,
            expanded,
            rows,
            ..
        } = &mut self.prompt
        {
            let visual = build_visual_rows(rows, expanded);
            let max_row = visual.len().saturating_sub(1);
            match mouse.kind {
                MouseEventKind::Down(MouseButton::Left) => {
                    if let OverlayMouseLayout::ResourceMonitor {
                        list,
                        items,
                        offset,
                    } = self.overlay_layout.active
                        && let Some(index) =
                            Self::overlay_row_at(list, offset, items, mouse.column, mouse.row)
                    {
                        *selected_row = index;
                        if let Some(VisualRow::Parent(row_idx)) = visual.get(index) {
                            let stat = &rows[*row_idx];
                            if let Some(pid) = stat.pid
                                && !stat.children.is_empty()
                                && !expanded.remove(&pid)
                            {
                                expanded.insert(pid);
                            }
                        }
                    }
                }
                MouseEventKind::ScrollUp => {
                    *selected_row = selected_row.saturating_sub(3);
                }
                MouseEventKind::ScrollDown => {
                    *selected_row = (*selected_row + 3).min(max_row);
                }
                _ => {}
            }
            *scroll_offset = (*selected_row).saturating_sub(5) as u16;
            return false;
        }

        if let PromptState::DebugInput {
            lines,
            scroll_offset,
        } = &mut self.prompt
        {
            // Scroll wheel navigates history without logging.
            match mouse.kind {
                MouseEventKind::ScrollUp => {
                    *scroll_offset = scroll_offset.saturating_sub(3);
                    return false;
                }
                MouseEventKind::ScrollDown => {
                    *scroll_offset = (*scroll_offset + 3).min(lines.len() as u16);
                    return false;
                }
                _ => {}
            }

            let kind_label = format!("{:?}", mouse.kind);
            let ts = Local::now().format("%H:%M:%S%.3f").to_string();
            lines.push(Line::from(vec![
                Span::styled(ts, Style::default().add_modifier(Modifier::DIM)),
                Span::raw(" │ "),
                Span::styled(
                    "Mouse",
                    Style::default().fg(self.theme.help_section_header_fg),
                ),
                Span::raw(" │ "),
                Span::styled(
                    format!("{:<18}", kind_label),
                    Style::default().add_modifier(Modifier::BOLD),
                ),
                Span::raw(" │ "),
                Span::styled(
                    format!("col={} row={}", mouse.column, mouse.row),
                    Style::default().add_modifier(Modifier::DIM),
                ),
            ]));

            // Auto-scroll to bottom.
            let total = lines.len() as u16;
            *scroll_offset = total;

            return false;
        }

        if matches!(self.prompt, PromptState::StartupCommandLogs(_))
            && let OverlayMouseLayout::StartupCommandLogs { area, body, .. } =
                self.overlay_layout.active
        {
            match mouse.kind {
                MouseEventKind::Down(MouseButton::Left)
                    if !contains_point(area, mouse.column, mouse.row) =>
                {
                    self.prompt = PromptState::None;
                    self.startup_log_selection = None;
                    return false;
                }
                MouseEventKind::Down(MouseButton::Left)
                | MouseEventKind::Drag(MouseButton::Left)
                | MouseEventKind::Up(MouseButton::Left)
                    if contains_point(body, mouse.column, mouse.row)
                        || self
                            .startup_log_selection
                            .as_ref()
                            .is_some_and(|selection| selection.dragging) =>
                {
                    return self.handle_startup_log_selection_mouse(mouse);
                }
                MouseEventKind::ScrollUp if contains_point(body, mouse.column, mouse.row) => {
                    if let PromptState::StartupCommandLogs(prompt) = &mut self.prompt {
                        scroll_startup_command_log(prompt, Some(body), -(MOUSE_WHEEL_LINES as i16));
                    }
                    return false;
                }
                MouseEventKind::ScrollDown if contains_point(body, mouse.column, mouse.row) => {
                    if let PromptState::StartupCommandLogs(prompt) = &mut self.prompt {
                        scroll_startup_command_log(prompt, Some(body), MOUSE_WHEEL_LINES as i16);
                    }
                    return false;
                }
                _ => {}
            }
        }

        // Watchdog: a press cannot outlive the modal it was made in. If
        // the prompt closed between Down and a follow-up event (e.g. via
        // a key handler), drop any stale press before doing anything
        // else.
        if matches!(self.prompt, PromptState::None) {
            self.pressed_button = None;
            return false;
        }

        match mouse.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                let Some(target) = self.prompt_mouse_target(mouse.column, mouse.row) else {
                    return false;
                };
                if let Some(button) = ButtonPressedTarget::from_prompt_target(target) {
                    // Buttons no longer fire on Down — record the press and
                    // wait for Up. Drag updates the `inside` flag so the
                    // press visual follows the cursor.
                    self.pressed_button = Some(PressedButton {
                        target: button,
                        inside: true,
                    });
                    false
                } else {
                    // Non-button targets (text inputs, list rows,
                    // checkboxes) keep their existing on-Down behavior.
                    self.dispatch_prompt_target_action(target, mouse)
                }
            }
            MouseEventKind::Drag(MouseButton::Left) => {
                if let Some(target) = self.pressed_button.map(|p| p.target) {
                    let still_inside = match self.prompt_mouse_target(mouse.column, mouse.row) {
                        Some(t) => ButtonPressedTarget::from_prompt_target(t) == Some(target),
                        None => false,
                    };
                    if let Some(pressed) = self.pressed_button.as_mut() {
                        pressed.inside = still_inside;
                    }
                }
                false
            }
            MouseEventKind::Up(MouseButton::Left) => {
                let Some(pressed) = self.pressed_button.take() else {
                    return false;
                };
                // Defensive re-hit-test: a fast click may not produce an
                // intervening Drag, so trust the cursor at Up time rather
                // than the last known `inside` flag.
                let still_inside = match self.prompt_mouse_target(mouse.column, mouse.row) {
                    Some(t) => ButtonPressedTarget::from_prompt_target(t) == Some(pressed.target),
                    None => false,
                };
                if still_inside {
                    self.activate_button(pressed.target)
                } else {
                    false
                }
            }
            _ => false,
        }
    }

    /// Dispatch on-Down behavior for non-button prompt targets — text
    /// inputs (cursor placement), list rows (selection + double-click
    /// activation), and checkboxes (toggle). These keep their original
    /// semantics since users expect immediate feedback for cursor moves
    /// and instant toggles.
    fn dispatch_prompt_target_action(
        &mut self,
        target: PromptMouseTarget,
        mouse: MouseEvent,
    ) -> bool {
        match target {
            PromptMouseTarget::CommandInput => {
                self.set_command_palette_cursor_from_mouse(mouse.column);
            }
            PromptMouseTarget::CommandItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                self.set_command_palette_selection(index);
                if double_click {
                    self.execute_selected_command_palette();
                }
            }
            PromptMouseTarget::BrowseProjectInput => {
                self.set_browser_input_cursor_from_mouse(mouse.column);
            }
            PromptMouseTarget::BrowseProjectItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                self.set_browser_selection(index);
                if double_click {
                    self.open_selected_browser_entry();
                }
            }
            PromptMouseTarget::PickEditorItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                self.set_pick_editor_selection(index);
                if double_click {
                    self.open_selected_pick_editor();
                }
            }
            PromptMouseTarget::PickProjectWorktreeItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                self.set_project_worktree_selection_from_visual_row(index);
                if double_click && let Err(err) = self.open_selected_project_worktree_agent_prompt()
                {
                    self.set_error(format!("{err:#}"));
                }
            }
            PromptMouseTarget::StartupCommandLogItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                self.select_startup_command_log_visual_index(index);
                if double_click {
                    self.open_selected_startup_command_log();
                }
            }
            PromptMouseTarget::ChangeThemeItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                let mut moved = false;
                if let PromptState::ChangeTheme(prompt) = &mut self.prompt
                    && index < prompt.options.len()
                    && prompt.selected != index
                {
                    prompt.selected = index;
                    moved = true;
                }
                if moved {
                    self.preview_change_theme_selection();
                }
                if double_click && let Err(err) = self.apply_change_theme() {
                    self.set_error(format!("{err:#}"));
                }
            }
            PromptMouseTarget::ChangeAgentProviderItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                self.set_change_agent_provider_selection(index);
                if double_click && let Err(err) = self.apply_change_agent_provider() {
                    self.set_error(format!("{err:#}"));
                }
            }
            PromptMouseTarget::ChangeDefaultProviderItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                self.set_change_default_provider_selection(index);
                if double_click && let Err(err) = self.apply_change_default_provider() {
                    self.set_error(format!("{err:#}"));
                }
            }
            PromptMouseTarget::ChangeProjectDefaultProviderItem(index) => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::CommandPalette, Some(index));
                self.set_change_project_default_provider_selection(index);
                if double_click && let Err(err) = self.apply_change_project_default_provider() {
                    self.set_error(format!("{err:#}"));
                }
            }
            PromptMouseTarget::RuntimeKillInput => {
                self.set_kill_running_search_cursor_from_mouse(mouse.column);
            }
            PromptMouseTarget::RuntimeKillItem(index) => {
                self.set_kill_running_hovered(index);
                self.toggle_hovered_kill_running_selection();
            }
            PromptMouseTarget::Checkbox(checkbox_id) => {
                self.toggle_overlay_checkbox(checkbox_id);
            }
            PromptMouseTarget::RenameInput => {
                self.set_rename_cursor_from_mouse(mouse.column);
            }
            PromptMouseTarget::NameNewAgentInput => {
                self.set_name_new_agent_cursor_from_mouse(mouse.column);
            }
            PromptMouseTarget::StartupCommandInput => {
                let double_click =
                    self.register_mouse_click(MouseClickTarget::StartupCommandInput, None);
                self.set_startup_command_cursor_from_mouse(mouse.column, mouse.row);
                if double_click {
                    self.input_target = InputTarget::StartupCommand;
                }
            }
            // Button targets are handled by `activate_button` and never
            // reach this path — `from_prompt_target` returns `Some(_)`
            // for all of them, sending mouse-down through the press
            // flow instead.
            PromptMouseTarget::ChangeAgentProviderCancel
            | PromptMouseTarget::ChangeAgentProviderApply
            | PromptMouseTarget::ChangeDefaultProviderCancel
            | PromptMouseTarget::ChangeDefaultProviderApply
            | PromptMouseTarget::ChangeProjectDefaultProviderCancel
            | PromptMouseTarget::ChangeProjectDefaultProviderApply
            | PromptMouseTarget::RuntimeKillCancel
            | PromptMouseTarget::RuntimeKillHovered
            | PromptMouseTarget::RuntimeKillSelected
            | PromptMouseTarget::RuntimeKillVisible
            | PromptMouseTarget::ConfirmKillCancel
            | PromptMouseTarget::ConfirmKillConfirm
            | PromptMouseTarget::ConfirmDeleteCancel
            | PromptMouseTarget::ConfirmDeleteConfirm
            | PromptMouseTarget::ConfirmDeleteTerminalCancel
            | PromptMouseTarget::ConfirmDeleteTerminalConfirm
            | PromptMouseTarget::ConfirmDeleteMacroCancel
            | PromptMouseTarget::ConfirmDeleteMacroConfirm
            | PromptMouseTarget::ConfirmQuitCancel
            | PromptMouseTarget::ConfirmQuitConfirm
            | PromptMouseTarget::ConfirmDiscardCancel
            | PromptMouseTarget::ConfirmDiscardConfirm
            | PromptMouseTarget::ConfirmNonDefaultBranchCancel
            | PromptMouseTarget::ConfirmNonDefaultBranchAdd
            | PromptMouseTarget::ConfirmUseExistingBranchCancel
            | PromptMouseTarget::ConfirmUseExistingBranchUse
            | PromptMouseTarget::ConfigReloadFailedClose
            | PromptMouseTarget::ConfigReloadFailedApply
            | PromptMouseTarget::AddProjectFailedOk
            | PromptMouseTarget::StartupCommandLogsClose => {
                debug_assert!(
                    false,
                    "button target {:?} should be dispatched via activate_button, not \
                     dispatch_prompt_target_action",
                    target
                );
            }
        }

        false
    }

    /// Fire the action for a button that was both pressed and released
    /// over its own bounds. Mirrors the previous on-Down dispatch
    /// arms one-for-one — only the trigger event differs.
    fn activate_button(&mut self, button: ButtonPressedTarget) -> bool {
        match button {
            ButtonPressedTarget::ChangeAgentProviderCancel => {
                self.prompt = PromptState::None;
                false
            }
            ButtonPressedTarget::ChangeAgentProviderApply => {
                if let Err(err) = self.apply_change_agent_provider() {
                    self.set_error(format!("{err:#}"));
                }
                false
            }
            ButtonPressedTarget::ChangeDefaultProviderCancel => {
                self.prompt = PromptState::None;
                false
            }
            ButtonPressedTarget::ChangeDefaultProviderApply => {
                if let Err(err) = self.apply_change_default_provider() {
                    self.set_error(format!("{err:#}"));
                }
                false
            }
            ButtonPressedTarget::ChangeProjectDefaultProviderCancel => {
                self.prompt = PromptState::None;
                false
            }
            ButtonPressedTarget::ChangeProjectDefaultProviderApply => {
                if let Err(err) = self.apply_change_project_default_provider() {
                    self.set_error(format!("{err:#}"));
                }
                false
            }
            ButtonPressedTarget::RuntimeKillCancel => {
                if let Err(e) =
                    self.execute_kill_running_footer_action(KillRunningFooterAction::Cancel)
                {
                    self.set_error(format!("{e:#}"));
                }
                false
            }
            ButtonPressedTarget::RuntimeKillHovered => {
                if let Err(e) =
                    self.execute_kill_running_footer_action(KillRunningFooterAction::Hovered)
                {
                    self.set_error(format!("{e:#}"));
                }
                false
            }
            ButtonPressedTarget::RuntimeKillSelected => {
                if let Err(e) =
                    self.execute_kill_running_footer_action(KillRunningFooterAction::Selected)
                {
                    self.set_error(format!("{e:#}"));
                }
                false
            }
            ButtonPressedTarget::RuntimeKillVisible => {
                if let Err(e) =
                    self.execute_kill_running_footer_action(KillRunningFooterAction::Visible)
                {
                    self.set_error(format!("{e:#}"));
                }
                false
            }
            ButtonPressedTarget::ConfirmKillCancel => self.resolve_confirm_kill_running(false),
            ButtonPressedTarget::ConfirmKillConfirm => self.resolve_confirm_kill_running(true),
            ButtonPressedTarget::ConfirmDeleteCancel => self.resolve_confirm_delete_agent(false),
            ButtonPressedTarget::ConfirmDeleteConfirm => self.resolve_confirm_delete_agent(true),
            ButtonPressedTarget::ConfirmDeleteTerminalCancel => {
                self.resolve_confirm_delete_terminal(false)
            }
            ButtonPressedTarget::ConfirmDeleteTerminalConfirm => {
                self.resolve_confirm_delete_terminal(true)
            }
            ButtonPressedTarget::ConfirmDeleteMacroCancel => {
                self.resolve_confirm_delete_macro(false)
            }
            ButtonPressedTarget::ConfirmDeleteMacroConfirm => {
                self.resolve_confirm_delete_macro(true)
            }
            ButtonPressedTarget::ConfirmQuitCancel => self.resolve_confirm_quit(false),
            ButtonPressedTarget::ConfirmQuitConfirm => self.resolve_confirm_quit(true),
            ButtonPressedTarget::ConfirmDiscardCancel => self.resolve_confirm_discard_file(false),
            ButtonPressedTarget::ConfirmDiscardConfirm => self.resolve_confirm_discard_file(true),
            ButtonPressedTarget::ConfirmNonDefaultBranchCancel => {
                self.prompt = PromptState::None;
                false
            }
            ButtonPressedTarget::ConfirmNonDefaultBranchAdd => {
                if let PromptState::ConfirmNonDefaultBranch { focus, .. } = &mut self.prompt {
                    *focus = ConfirmNonDefaultBranchFocus::Add;
                }
                self.resolve_confirm_non_default_branch()
            }
            ButtonPressedTarget::ConfirmUseExistingBranchCancel => {
                self.resolve_confirm_use_existing_branch(false)
            }
            ButtonPressedTarget::ConfirmUseExistingBranchUse => {
                self.resolve_confirm_use_existing_branch(true)
            }
            ButtonPressedTarget::ConfigReloadFailedClose => {
                self.resolve_config_reload_failed(false)
            }
            ButtonPressedTarget::ConfigReloadFailedApply => self.resolve_config_reload_failed(true),
            ButtonPressedTarget::AddProjectFailedOk => self.resolve_add_project_failed(),
            ButtonPressedTarget::StartupCommandLogsClose => {
                self.prompt = PromptState::None;
                false
            }
        }
    }

    fn resolve_config_reload_failed(&mut self, apply: bool) -> bool {
        let recover = matches!(
            &self.prompt,
            PromptState::ConfigReloadFailed {
                recover_old_config: true,
                ..
            }
        );
        if apply && !recover {
            self.set_info("Check \"Recover last working config\" before restoring config.toml.");
            return false;
        }
        self.prompt = PromptState::None;
        if apply && recover {
            self.spawn_config_recover_worker();
            self.set_busy("Restoring the last working config.toml.");
        }
        false
    }

    fn activate_selected_left_item(&mut self) -> Result<()> {
        match self.left_items().get(self.selected_left) {
            Some(LeftItem::Project(project_index)) => {
                let project = &self.projects[*project_index];
                if project.path_missing {
                    self.set_warning(format!("Project path not found: {}", project.path));
                    return Ok(());
                }
                let project_id = project.id.clone();
                let has_sessions = self.sessions.iter().any(|s| s.project_id == project_id);
                if has_sessions {
                    if self.collapsed_projects.contains(&project_id) {
                        self.collapsed_projects.remove(&project_id);
                        self.rebuild_left_items();
                    }
                    if let Some(pos) = self.left_items().iter().position(|item| {
                        matches!(item, LeftItem::Session(si) if self.sessions[*si].project_id == project_id)
                    }) {
                        self.selected_left = pos;
                        self.center_mode = CenterMode::Agent;
                        self.focus = FocusPane::Center;
                        self.reload_changed_files();
                        if self
                            .selected_session()
                            .map(|s| self.providers.contains_key(&s.id))
                            .unwrap_or(false)
                        {
                            self.input_target = InputTarget::Agent;
                            self.fullscreen_overlay = FullscreenOverlay::Agent;
                        } else if self.selected_session().is_some() {
                            self.reconnect_selected_session()?;
                        }
                    }
                } else {
                    self.create_agent_for_selected_project()?;
                }
            }
            Some(LeftItem::Session(_)) => {
                self.center_mode = CenterMode::Agent;
                self.focus = FocusPane::Center;
                self.reload_changed_files();
                if self
                    .selected_session()
                    .map(|s| self.providers.contains_key(&s.id))
                    .unwrap_or(false)
                {
                    self.input_target = InputTarget::Agent;
                    self.fullscreen_overlay = FullscreenOverlay::Agent;
                } else if self.selected_session().is_some() {
                    self.reconnect_selected_session()?;
                }
            }
            Some(LeftItem::EmptyProjectsSpacer) => {}
            Some(LeftItem::EmptyProjectsSeparator) => {}
            None => {}
        }
        Ok(())
    }

    fn activate_selected_left_item_from_mouse(&mut self) {
        if let Err(err) = self.activate_selected_left_item() {
            self.set_error(format!("Mouse activation failed: {err}"));
        }
    }

    pub(crate) fn activate_center_agent(&mut self) -> Result<()> {
        if !matches!(self.center_mode, CenterMode::Agent) {
            return Ok(());
        }
        if self.selected_session().is_some()
            && self
                .selected_session()
                .map(|s| self.providers.contains_key(&s.id))
                .unwrap_or(false)
        {
            self.reset_pty_scrollback();
            self.input_target = InputTarget::Agent;
            self.fullscreen_overlay = FullscreenOverlay::Agent;
            let exit_key = self.bindings.label_for(Action::ExitInteractive);
            self.set_info(format!(
                "Interactive mode. Keys forwarded to agent. {exit_key} exits."
            ));
        } else if self.selected_session().is_some() {
            self.reconnect_selected_session()?;
        } else {
            let n = self.bindings.label_for(Action::NewAgent);
            self.set_error(format!(
                "No agent selected. Press \"{n}\" to create a new one."
            ));
        }
        Ok(())
    }

    fn activate_center_agent_from_mouse(&mut self) {
        if let Err(err) = self.activate_center_agent() {
            self.set_error(format!("Mouse activation failed: {err}"));
        }
    }

    fn open_selected_file_diff_from_mouse(&mut self) {
        if let Err(err) = self.open_diff_for_selected_file() {
            self.set_error(format!("Mouse activation failed: {err}"));
        }
    }

    fn set_file_selection(&mut self, section: RightSection, index: Option<usize>) {
        self.focus = FocusPane::Files;
        self.input_target = InputTarget::None;
        self.fullscreen_overlay = FullscreenOverlay::None;
        self.right_section = section;
        if let Some(index) = index {
            self.files_index = index;
        }
        self.clamp_files_cursor();
    }

    fn engage_commit_input(&mut self) {
        self.focus = FocusPane::Files;
        self.right_section = RightSection::CommitInput;
        self.input_target = InputTarget::CommitMessage;
        self.fullscreen_overlay = FullscreenOverlay::None;
    }

    fn set_commit_cursor_from_mouse(&mut self, column: u16, row: u16) {
        let Some(text_area) = self.mouse_layout.commit_text_area else {
            self.commit_input.move_end();
            return;
        };
        let display_row = usize::from(row.saturating_sub(text_area.y));
        let display_col = usize::from(column.saturating_sub(text_area.x));
        self.commit_input
            .set_cursor_from_display_pos(display_row, display_col);
    }

    fn scroll_commit_input(&mut self, down: bool) {
        let delta = if down {
            MOUSE_WHEEL_LINES as isize
        } else {
            -(MOUSE_WHEEL_LINES as isize)
        };
        self.commit_input.scroll_by(delta);
    }

    fn scroll_file_selection(&mut self, section: RightSection, down: bool) {
        self.set_file_selection(section, Some(self.files_index));
        if self.right_section == RightSection::CommitInput {
            return;
        }

        let len = self.current_files_len();
        if len == 0 {
            return;
        }

        if down {
            if self.files_index + 1 < len {
                self.files_index += 1;
            }
        } else if self.files_index > 0 {
            self.files_index -= 1;
        }
    }

    fn handle_left_mouse_wheel(&mut self, down: bool, column: u16, row: u16) {
        let target_index = match self.mouse_target(column, row) {
            Some(MouseTarget::LeftRow(index)) => index,
            _ => self.selected_left,
        };
        self.set_left_selection(target_index);

        if down {
            if let Some(next) = self.next_selectable_left_item_after(self.selected_left) {
                self.selected_left = next;
                self.reload_changed_files();
            }
        } else if let Some(prev) = self.previous_selectable_left_item_before(self.selected_left) {
            self.selected_left = prev;
            self.reload_changed_files();
        }
    }

    fn handle_center_mouse_wheel(&mut self, mouse: MouseEvent) {
        self.focus = FocusPane::Center;
        if let CenterMode::Diff { ref mut scroll, .. } = self.center_mode {
            let delta = MOUSE_WHEEL_LINES as u16;
            if matches!(mouse.kind, MouseEventKind::ScrollDown) {
                let max_scroll = self
                    .last_diff_visual_lines
                    .saturating_sub(self.last_diff_height.max(1));
                *scroll = (*scroll + delta).min(max_scroll);
            } else if matches!(mouse.kind, MouseEventKind::ScrollUp) {
                *scroll = scroll.saturating_sub(delta);
            }
            return;
        }

        let Some(provider) = self.selected_terminal_surface_client() else {
            return;
        };

        // Always handle scroll as host scrollback — never forward to the child
        // process. The outer terminal's EnableMouseCapture means dux owns the
        // mouse; forwarding scroll events causes confusion.
        provider.scroll(
            matches!(mouse.kind, MouseEventKind::ScrollUp),
            MOUSE_WHEEL_LINES,
        );
    }

    fn update_dragged_panes(&mut self, column: u16, row: u16) {
        let body = self.mouse_layout.body;
        if body.width == 0 {
            return;
        }

        let body_right = body.x + body.width;
        match self.mouse_drag {
            Some(ResizeDragState::LeftDivider) => {
                let columns = column
                    .saturating_sub(body.x)
                    .saturating_add(1)
                    .clamp(1, body.width);
                self.set_left_width_pct(pct_from_columns(columns, body.width));
            }
            Some(ResizeDragState::RightDivider) => {
                let columns = body_right.saturating_sub(column).clamp(1, body.width);
                self.set_right_width_pct(pct_from_columns(columns, body.width));
            }
            Some(ResizeDragState::TerminalDivider) => {
                let left = self.mouse_layout.left;
                if left.height > 0 {
                    // Terminal height = distance from mouse row to bottom of left pane.
                    let left_bottom = left.y + left.height;
                    let term_rows = left_bottom.saturating_sub(row).clamp(1, left.height);
                    let pct = pct_from_columns(term_rows, left.height); // reuse same % helper
                    self.terminal_pane_height_pct =
                        pct.clamp(MIN_TERMINAL_PANE_HEIGHT_PCT, MAX_TERMINAL_PANE_HEIGHT_PCT);
                }
            }
            Some(ResizeDragState::StagedDivider) => {
                let right = self.mouse_layout.right;
                if right.height > 0 {
                    // Staged height = distance from mouse row to bottom of right pane.
                    let right_bottom = right.y + right.height;
                    let staged_rows = right_bottom.saturating_sub(row).clamp(1, right.height);
                    let pct = pct_from_columns(staged_rows, right.height);
                    self.staged_pane_height_pct =
                        pct.clamp(MIN_STAGED_PANE_HEIGHT_PCT, MAX_STAGED_PANE_HEIGHT_PCT);
                }
            }
            Some(ResizeDragState::CommitDivider) => {
                // The commit divider resizes the split between "Staged Changes"
                // and "Commit Message".  We compute relative to the staged
                // sub-area of the right pane.  The sub-area spans from where
                // the staged section starts to the bottom of the right pane.
                // Using the right pane bottom as reference keeps the
                // calculation stable even when layout rects are stale during
                // multi-frame drags.
                if let Some(staged) = self.mouse_layout.staged_list {
                    let right = self.mouse_layout.right;
                    // Sub-area = staged block top to right pane bottom.
                    let sub_top = staged.y.saturating_sub(1); // include staged border
                    let sub_bottom = right.y + right.height;
                    let sub_height = sub_bottom.saturating_sub(sub_top);
                    if sub_height > 0 {
                        let commit_rows = sub_bottom.saturating_sub(row).clamp(1, sub_height);
                        let pct = pct_from_columns(commit_rows, sub_height);
                        self.commit_pane_height_pct =
                            pct.clamp(MIN_COMMIT_PANE_HEIGHT_PCT, MAX_COMMIT_PANE_HEIGHT_PCT);
                    }
                }
            }
            None => {}
        }
    }

    fn persist_pane_widths(&mut self) {
        if self.config.ui.left_width_pct != self.left_width_pct
            || self.config.ui.right_width_pct != self.right_width_pct
            || self.config.ui.terminal_pane_height_pct != self.terminal_pane_height_pct
            || self.config.ui.staged_pane_height_pct != self.staged_pane_height_pct
            || self.config.ui.commit_pane_height_pct != self.commit_pane_height_pct
        {
            self.config.ui.left_width_pct = self.left_width_pct;
            self.config.ui.right_width_pct = self.right_width_pct;
            self.config.ui.terminal_pane_height_pct = self.terminal_pane_height_pct;
            self.config.ui.staged_pane_height_pct = self.staged_pane_height_pct;
            self.config.ui.commit_pane_height_pct = self.commit_pane_height_pct;
            if let Err(err) = save_config(&self.paths.config_path, &self.config, &self.bindings) {
                self.set_error(format!("Couldn't persist pane sizes to config: {err:#}"));
            }
        }
    }

    pub(crate) fn handle_mouse(&mut self, mouse: MouseEvent) -> bool {
        if !matches!(self.prompt, PromptState::None) {
            return self.handle_prompt_mouse(mouse);
        }
        // Watchdog: a press can only be set inside `handle_prompt_mouse`,
        // but the modal that armed it may have been closed by an
        // unrelated path (key handler, action callback). Drop any stale
        // press the moment we see a non-prompt mouse event so it cannot
        // outlive its dialog.
        self.pressed_button = None;

        if let Some(ref mut scroll) = self.help_scroll {
            let max_help = self
                .last_help_lines
                .saturating_sub(self.last_help_height.max(1));
            match mouse.kind {
                MouseEventKind::ScrollDown => {
                    *scroll = (*scroll + MOUSE_WHEEL_LINES as u16).min(max_help)
                }
                MouseEventKind::ScrollUp => {
                    *scroll = scroll.saturating_sub(MOUSE_WHEEL_LINES as u16)
                }
                _ => {}
            }
            return false;
        }

        if matches!(self.fullscreen_overlay, FullscreenOverlay::StartupLog) {
            let inside_log = self
                .mouse_layout
                .agent_term
                .is_some_and(|rect| contains_point(rect, mouse.column, mouse.row));
            match mouse.kind {
                MouseEventKind::Down(MouseButton::Left)
                | MouseEventKind::Drag(MouseButton::Left)
                | MouseEventKind::Up(MouseButton::Left)
                    if inside_log
                        || self
                            .terminal_selection
                            .as_ref()
                            .is_some_and(|selection| selection.dragging) =>
                {
                    self.handle_terminal_selection_mouse(mouse);
                }
                MouseEventKind::Down(MouseButton::Left) if !inside_log => {
                    self.close_top_overlay();
                }
                MouseEventKind::ScrollDown if inside_log => {
                    self.terminal_selection = None;
                    self.scroll_startup_log_viewer(MOUSE_WHEEL_LINES as i16);
                }
                MouseEventKind::ScrollUp if inside_log => {
                    self.terminal_selection = None;
                    self.scroll_startup_log_viewer(-(MOUSE_WHEEL_LINES as i16));
                }
                _ => {}
            }
            return false;
        }

        match mouse.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(drag) = self.resize_drag_at_mouse(mouse.column, mouse.row) {
                    self.mouse_drag = Some(drag);
                    self.update_dragged_panes(mouse.column, mouse.row);
                    return false;
                }

                match self.mouse_target(mouse.column, mouse.row) {
                    Some(MouseTarget::LeftPane) => {
                        self.focus = FocusPane::Left;
                        self.input_target = InputTarget::None;
                        self.fullscreen_overlay = FullscreenOverlay::None;
                    }
                    Some(MouseTarget::LeftRow(index)) => {
                        let double_click =
                            self.register_mouse_click(MouseClickTarget::LeftPane, Some(index));
                        self.left_section = LeftSection::Projects;
                        self.set_left_selection(index);
                        if double_click {
                            self.activate_selected_left_item_from_mouse();
                        }
                    }
                    Some(MouseTarget::TerminalRow(index)) => {
                        let double_click =
                            self.register_mouse_click(MouseClickTarget::LeftPane, Some(index));
                        self.focus = FocusPane::Left;
                        self.left_section = LeftSection::Terminals;
                        self.selected_terminal_index = index;
                        self.input_target = InputTarget::None;
                        self.fullscreen_overlay = FullscreenOverlay::None;
                        if double_click {
                            let _ = self.open_terminal_from_terminal_list();
                        }
                    }
                    Some(MouseTarget::TerminalPane) => {
                        self.focus = FocusPane::Left;
                        self.left_section = LeftSection::Terminals;
                        self.input_target = InputTarget::None;
                        self.fullscreen_overlay = FullscreenOverlay::None;
                    }
                    Some(MouseTarget::Center) => {
                        let double_click =
                            self.register_mouse_click(MouseClickTarget::CenterPane, None);
                        self.focus = FocusPane::Center;
                        if double_click {
                            self.activate_center_agent_from_mouse();
                        }
                    }
                    Some(MouseTarget::FilesPane) => {
                        self.focus = FocusPane::Files;
                        self.input_target = InputTarget::None;
                        self.fullscreen_overlay = FullscreenOverlay::None;
                    }
                    Some(MouseTarget::UnstagedFile(index)) => {
                        self.set_file_selection(RightSection::Unstaged, index);
                        if index.is_some() {
                            let double_click =
                                self.register_mouse_click(MouseClickTarget::UnstagedPane, index);
                            if double_click {
                                self.open_selected_file_diff_from_mouse();
                            }
                        }
                    }
                    Some(MouseTarget::StagedFile(index)) => {
                        self.set_file_selection(RightSection::Staged, index);
                        if index.is_some() {
                            let double_click =
                                self.register_mouse_click(MouseClickTarget::StagedPane, index);
                            if double_click {
                                self.open_selected_file_diff_from_mouse();
                            }
                        }
                    }
                    Some(MouseTarget::CommitChrome) => {
                        self.set_file_selection(RightSection::CommitInput, None);
                    }
                    Some(MouseTarget::CommitText) => {
                        let ready_to_edit = self.focus == FocusPane::Files
                            && self.right_section == RightSection::CommitInput
                            && self.input_target != InputTarget::CommitMessage;
                        if ready_to_edit || self.input_target == InputTarget::CommitMessage {
                            self.engage_commit_input();
                            self.set_commit_cursor_from_mouse(mouse.column, mouse.row);
                        } else {
                            self.set_file_selection(RightSection::CommitInput, None);
                        }
                    }
                    None => {}
                }
            }
            MouseEventKind::Drag(MouseButton::Left) if self.mouse_drag.is_some() => {
                self.update_dragged_panes(mouse.column, mouse.row);
            }
            MouseEventKind::Up(MouseButton::Left) if self.mouse_drag.take().is_some() => {
                self.persist_pane_widths();
            }
            MouseEventKind::ScrollDown => match self.mouse_target(mouse.column, mouse.row) {
                Some(MouseTarget::LeftRow(_)) => {
                    self.handle_left_mouse_wheel(true, mouse.column, mouse.row)
                }
                Some(MouseTarget::Center) => self.handle_center_mouse_wheel(mouse),
                Some(MouseTarget::UnstagedFile(_)) => {
                    self.scroll_file_selection(RightSection::Unstaged, true)
                }
                Some(MouseTarget::StagedFile(_)) => {
                    self.scroll_file_selection(RightSection::Staged, true)
                }
                Some(MouseTarget::CommitChrome | MouseTarget::CommitText) => {
                    self.focus = FocusPane::Files;
                    self.right_section = RightSection::CommitInput;
                    self.scroll_commit_input(true);
                }
                _ => {}
            },
            MouseEventKind::ScrollUp => match self.mouse_target(mouse.column, mouse.row) {
                Some(MouseTarget::LeftRow(_)) => {
                    self.handle_left_mouse_wheel(false, mouse.column, mouse.row)
                }
                Some(MouseTarget::Center) => self.handle_center_mouse_wheel(mouse),
                Some(MouseTarget::UnstagedFile(_)) => {
                    self.scroll_file_selection(RightSection::Unstaged, false)
                }
                Some(MouseTarget::StagedFile(_)) => {
                    self.scroll_file_selection(RightSection::Staged, false)
                }
                Some(MouseTarget::CommitChrome | MouseTarget::CommitText) => {
                    self.focus = FocusPane::Files;
                    self.right_section = RightSection::CommitInput;
                    self.scroll_commit_input(false);
                }
                _ => {}
            },
            _ => {}
        }
        false
    }

    // -- Terminal text selection helpers --

    /// Map screen coordinates to terminal grid position.
    /// Returns `None` if the point is outside the terminal area.
    fn screen_to_grid(&self, screen_col: u16, screen_row: u16) -> Option<TermGridPos> {
        let term_area = self.mouse_layout.agent_term?;
        let (col, row) = relative_point(term_area, screen_col, screen_row)?;
        Some(TermGridPos { row, col })
    }

    /// Map screen coordinates to terminal grid position, clamping to the
    /// terminal area edges. Used during drag so the selection extends to the
    /// nearest boundary when the mouse leaves the terminal area.
    fn screen_to_grid_clamped(&self, screen_col: u16, screen_row: u16) -> Option<TermGridPos> {
        let term_area = self.mouse_layout.agent_term?;
        let (col, row) = relative_point_clamped(term_area, screen_col, screen_row);
        Some(TermGridPos { row, col })
    }

    /// Handle a mouse event for terminal text selection (click, drag, release).
    fn handle_terminal_selection_mouse(&mut self, mouse_ev: MouseEvent) {
        match mouse_ev.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(pos) = self.screen_to_grid(mouse_ev.column, mouse_ev.row) {
                    self.terminal_selection = Some(TerminalSelection {
                        anchor: pos,
                        end: pos,
                        dragging: true,
                    });
                }
            }
            MouseEventKind::Drag(MouseButton::Left) => {
                let pos = self.screen_to_grid_clamped(mouse_ev.column, mouse_ev.row);
                if let Some(sel) = &mut self.terminal_selection
                    && sel.dragging
                    && let Some(pos) = pos
                {
                    sel.end = pos;
                }
            }
            MouseEventKind::Up(MouseButton::Left) => {
                if let Some(sel) = &mut self.terminal_selection
                    && sel.dragging
                {
                    sel.dragging = false;
                    if sel.anchor == sel.end {
                        // Single click, no actual selection.
                        self.terminal_selection = None;
                    } else {
                        self.copy_terminal_selection();
                    }
                }
            }
            _ => {}
        }
    }

    /// Extract text from the terminal snapshot within the active selection
    /// and copy it to the system clipboard.
    fn copy_terminal_selection(&mut self) {
        if matches!(self.fullscreen_overlay, FullscreenOverlay::StartupLog) {
            self.copy_startup_log_selection();
            return;
        }
        let sel = match self.terminal_selection.clone() {
            Some(s) => s,
            None => return,
        };
        let (start, end) = sel.ordered();

        // Refresh snapshot to get current content.
        self.refresh_snapshot_buf();

        let mut lines: Vec<String> = Vec::new();
        let mut current_row = start.row;
        let mut current_line = String::new();

        for cell in &self.snapshot_buf.cells {
            if !sel.contains(cell.row, cell.col) {
                continue;
            }
            if cell.row != current_row {
                // Flush the previous line (trim trailing whitespace).
                lines.push(current_line.trim_end().to_string());
                // Insert empty lines for any gap rows.
                for _ in (current_row + 1)..cell.row {
                    lines.push(String::new());
                }
                current_line = String::new();
                current_row = cell.row;
            }
            // Pad with spaces if columns are not contiguous (sparse cells).
            let expected_col = if current_line.is_empty() {
                start.col.min(cell.col)
            } else {
                // Approximate: one char per column.
                let line_cols = current_line.chars().count() as u16;
                if cell.row == start.row {
                    start.col + line_cols
                } else {
                    line_cols
                }
            };
            if cell.col > expected_col {
                for _ in 0..(cell.col - expected_col) {
                    current_line.push(' ');
                }
            }
            current_line.push_str(&cell.symbol);
        }
        // Flush last line.
        if !current_line.is_empty() || !lines.is_empty() {
            lines.push(current_line.trim_end().to_string());
            // Fill gap rows between last populated row and end.
            for _ in (current_row + 1)..=end.row {
                lines.push(String::new());
            }
        }

        let text = lines.join("\n");
        if !text.is_empty() {
            let _ = self.clipboard.copy_text(
                &text,
                "Terminal text copied to clipboard.",
                &self.worker_tx,
            );
        }
    }

    fn screen_to_startup_log_grid(&self, screen_col: u16, screen_row: u16) -> Option<TermGridPos> {
        let OverlayMouseLayout::StartupCommandLogs { body, .. } = self.overlay_layout.active else {
            return None;
        };
        let (col, row) = relative_point(body, screen_col, screen_row)?;
        let scroll_offset = match &self.prompt {
            PromptState::StartupCommandLogs(prompt) => prompt.scroll_offset,
            _ => 0,
        };
        Some(TermGridPos {
            row: scroll_offset.saturating_add(row),
            col,
        })
    }

    fn screen_to_startup_log_grid_clamped(
        &self,
        screen_col: u16,
        screen_row: u16,
    ) -> Option<TermGridPos> {
        let OverlayMouseLayout::StartupCommandLogs { body, .. } = self.overlay_layout.active else {
            return None;
        };
        let scroll_offset = match &self.prompt {
            PromptState::StartupCommandLogs(prompt) => prompt.scroll_offset,
            _ => 0,
        };
        let (col, row) = relative_point_clamped(body, screen_col, screen_row);
        Some(TermGridPos {
            row: scroll_offset.saturating_add(row),
            col,
        })
    }

    fn handle_startup_log_selection_mouse(&mut self, mouse_ev: MouseEvent) -> bool {
        match mouse_ev.kind {
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(pos) = self.screen_to_startup_log_grid(mouse_ev.column, mouse_ev.row) {
                    self.startup_log_selection = Some(TerminalSelection {
                        anchor: pos,
                        end: pos,
                        dragging: true,
                    });
                    return true;
                }
            }
            MouseEventKind::Drag(MouseButton::Left) => {
                let pos = self.screen_to_startup_log_grid_clamped(mouse_ev.column, mouse_ev.row);
                if let Some(sel) = &mut self.startup_log_selection
                    && sel.dragging
                    && let Some(pos) = pos
                {
                    sel.end = pos;
                    return true;
                }
            }
            MouseEventKind::Up(MouseButton::Left) => {
                if let Some(sel) = &mut self.startup_log_selection
                    && sel.dragging
                {
                    sel.dragging = false;
                    if sel.anchor == sel.end {
                        self.startup_log_selection = None;
                    } else {
                        self.copy_startup_log_selection();
                    }
                    return true;
                }
            }
            _ => {}
        }
        false
    }

    fn copy_startup_log_selection(&mut self) {
        let (sel, content, width) = match (
            self.fullscreen_overlay,
            &self.startup_log_viewer,
            self.mouse_layout.agent_term,
            &self.prompt,
            self.overlay_layout.active,
        ) {
            (FullscreenOverlay::StartupLog, Some(viewer), Some(body), _, _) => {
                let Some(sel) = self.terminal_selection.clone() else {
                    return;
                };
                (sel, viewer.content.clone(), body.width)
            }
            (
                _,
                _,
                _,
                PromptState::StartupCommandLogs(prompt),
                OverlayMouseLayout::StartupCommandLogs { body, .. },
            ) => {
                let Some(sel) = self.startup_log_selection.clone() else {
                    return;
                };
                (sel, prompt.content.clone(), body.width)
            }
            _ => return,
        };
        let lines = startup_command_log_visual_lines(&content, width);
        let (start, end) = sel.ordered();
        let mut selected = Vec::new();
        for row in start.row..=end.row {
            let Some(line) = lines.get(row as usize) else {
                continue;
            };
            let start_col = if row == start.row {
                start.col as usize
            } else {
                0
            };
            let end_col = if row == end.row {
                end.col as usize + 1
            } else {
                line.chars().count()
            };
            let text = line
                .chars()
                .skip(start_col)
                .take(end_col.saturating_sub(start_col))
                .collect::<String>();
            selected.push(text.trim_end().to_string());
        }
        let text = selected.join("\n");
        if !text.is_empty() {
            let _ = self.clipboard.copy_text(
                &text,
                "Startup command log text copied to clipboard.",
                &self.worker_tx,
            );
        }
    }

    /// Reset all state associated with interactive mode (agent or terminal)
    /// and show a status message. Used by ExitInteractive key handling,
    /// mouse-click-outside-overlay, and the loading-phase exit path.
    pub(crate) fn exit_interactive_mode(&mut self) {
        let return_to_terminal_list =
            matches!(self.input_target, InputTarget::Terminal) && self.terminal_return_to_list;
        let return_to_projects = matches!(self.input_target, InputTarget::Agent);
        self.input_target = InputTarget::None;
        self.fullscreen_overlay = FullscreenOverlay::None;
        self.session_surface = SessionSurface::Agent;
        self.terminal_selection = None;
        self.in_bracket_paste = false;
        self.raw_input_buf.clear();
        self.raw_input_parser.clear();
        self.loading_input_buf.clear();
        if return_to_terminal_list {
            self.left_section = LeftSection::Terminals;
            self.clamp_terminal_cursor();
            self.focus = FocusPane::Left;
        } else if return_to_projects {
            self.left_section = LeftSection::Projects;
            self.focus = FocusPane::Left;
        }
        self.set_info("Exited interactive mode.");
    }

    /// Scan `loading_input_buf` for an ExitInteractive key sequence or a
    /// mouse left-click outside the fullscreen overlay. Returns `true` if
    /// either was found and interactive mode was exited. Used during the
    /// loading phase when all other input is suppressed.
    pub(crate) fn scan_loading_phase_exit(&mut self) -> bool {
        let (sequences, _) = crate::raw_input::split_sequences(&self.loading_input_buf);
        for seq in &sequences {
            // Check for ExitInteractive keybinding.
            if let Some((Action::ExitInteractive, _)) =
                self.interactive_patterns.match_sequence(seq)
            {
                self.exit_interactive_mode();
                return true;
            }
            // Check for a left-click outside the fullscreen overlay, mirroring
            // the click-outside dismiss in process_raw_input_bytes.
            if let Some(mouse_ev) = crate::raw_input::parse_sgr_mouse(seq) {
                let outside_overlay =
                    matches!(mouse_ev.kind, MouseEventKind::Down(MouseButton::Left))
                        && !self.mouse_layout.agent_term.is_some_and(|rect| {
                            contains_point(rect, mouse_ev.column, mouse_ev.row)
                        });
                if outside_overlay {
                    self.exit_interactive_mode();
                    return true;
                }
            }
        }
        false
    }
}

fn set_create_agent_request_custom_name(request: &mut CreateAgentRequest, name: String) {
    match request {
        CreateAgentRequest::NewProject { custom_name, .. }
        | CreateAgentRequest::ForkSession { custom_name, .. }
        | CreateAgentRequest::PullRequest { custom_name, .. }
        | CreateAgentRequest::ExistingManagedWorktree { custom_name, .. }
        | CreateAgentRequest::ForkExternalWorktree { custom_name, .. } => {
            *custom_name = Some(name);
        }
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;
    use std::sync::atomic::AtomicBool;
    use std::sync::{Arc, Mutex, mpsc};

    use super::DOUBLE_CLICK_THRESHOLD;
    use super::components::{ButtonPressedTarget, PressedButton};
    use crate::app::{
        AgentLaunchKind, App, BranchWarningKind, CenterMode, ConfigReloadFailedFocus,
        ConfirmKillRunningPrompt, ConfirmNonDefaultBranchFocus, CreateAgentBranchInspection,
        CreateAgentRequest, DeleteAgentFocus, FocusPane, FullscreenOverlay, InputTarget,
        KillRunningAction, KillRunningFocus, KillRunningFooterAction, KillRunningPrompt,
        KillableRuntime, KillableRuntimeKind, LeftItem, LeftSection, MacroBarState,
        MouseClickTarget, MouseLayoutState, NameNewAgentFocus, NonDefaultBranchAction,
        OverlayCheckbox, OverlayCheckboxId, OverlayMouseLayout, OverlayMouseLayoutState,
        PickProjectWorktreePrompt, ProcessInfo, ProjectWorktreeEntry, PromptState, PullTarget,
        ResolvedPullRequest, ResourceStats, RightSection, RuntimeTargetId, StartupCommandLogPrompt,
        TextInput, WorkerEvent,
    };
    use crate::clipboard::Clipboard;
    use crate::config::{Config, DuxPaths, ProjectConfig};
    use crate::editor::{DetectedEditor, EditorKind};
    use crate::keybindings::{Action, BINDING_DEFS, BindingScope, RuntimeBindings};
    use crate::model::{
        AgentSession, ChangedFile, CompanionTerminalStatus, PrInfo, PrState, Project,
        ProjectBranchStatus, ProviderKind, SessionStatus, SessionSurface,
    };
    use crate::pty::PtyClient;
    use crate::statusline::StatusLine;
    use crate::storage::SessionStore;
    use crate::theme::Theme;
    use chrono::Utc;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
    use ratatui::layout::Rect;
    use ratatui::text::Line;
    use std::process::Command;
    use tempfile::tempdir;

    fn default_bindings() -> RuntimeBindings {
        RuntimeBindings::new(
            |action| {
                BINDING_DEFS
                    .iter()
                    .find(|d| d.action == action)
                    .map(|d| d.default_keys.to_vec())
                    .unwrap_or_default()
            },
            true,
        )
    }

    fn bindings_with_overrides(overrides: &[(Action, &[&str])]) -> RuntimeBindings {
        RuntimeBindings::new(
            |action| {
                if let Some((_, keys)) =
                    overrides.iter().find(|(candidate, _)| *candidate == action)
                {
                    return keys
                        .iter()
                        .map(|key| crokey::parse(key).expect("valid test binding"))
                        .collect();
                }
                BINDING_DEFS
                    .iter()
                    .find(|d| d.action == action)
                    .map(|d| d.default_keys.to_vec())
                    .unwrap_or_default()
            },
            true,
        )
    }

    fn run_git(cwd: &std::path::Path, args: &[&str]) {
        let output = Command::new("git")
            .args(args)
            .current_dir(cwd)
            .output()
            .expect("run git");
        assert!(
            output.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
    }

    fn init_test_repo(path: &std::path::Path) {
        run_git(path, &["init", "-b", "main"]);
        run_git(path, &["config", "user.name", "test"]);
        run_git(path, &["config", "user.email", "t@t"]);
        run_git(path, &["commit", "--allow-empty", "-m", "init"]);
    }

    fn set_remote_default(path: &std::path::Path, branch: &str) {
        run_git(
            path,
            &[
                "update-ref",
                &format!("refs/remotes/origin/{branch}"),
                "HEAD",
            ],
        );
        run_git(
            path,
            &[
                "symbolic-ref",
                "refs/remotes/origin/HEAD",
                &format!("refs/remotes/origin/{branch}"),
            ],
        );
    }

    fn complete_create_agent_branch_inspection(app: &mut App, branch: &str, leading_branch: &str) {
        let project = app.projects[0].clone();
        app.worker_tx
            .send(WorkerEvent::CreateAgentBranchInspected {
                project,
                result: Ok(CreateAgentBranchInspection {
                    current_branch: branch.to_string(),
                    leading_branch: leading_branch.to_string(),
                }),
            })
            .unwrap();
        app.drain_events();
    }

    fn test_app(bindings: RuntimeBindings) -> App {
        let tmp = tempdir().expect("tempdir");
        let root = tmp.path().to_path_buf();
        std::mem::forget(tmp);
        init_test_repo(&root);

        let paths = DuxPaths {
            config_path: root.join("config.toml"),
            sessions_db_path: root.join("sessions.sqlite3"),
            worktrees_root: root.join("worktrees"),
            lock_path: root.join("dux.lock"),
            root: root.clone(),
        };
        std::fs::create_dir_all(&paths.worktrees_root).expect("worktrees dir");
        let session_store = SessionStore::open(&paths.sessions_db_path).expect("session store");
        let now = Utc::now();
        let project = Project {
            id: "project-1".to_string(),
            name: "demo".to_string(),
            path: root.to_string_lossy().to_string(),
            explicit_default_provider: None,
            default_provider: ProviderKind::from_str("codex"),
            leading_branch: Some("main".to_string()),
            auto_reopen_agents: None,
            startup_command: None,
            env: Default::default(),
            current_branch: "main".to_string(),
            branch_status: ProjectBranchStatus::Unknown,
            path_missing: false,
        };
        session_store
            .upsert_project(&ProjectConfig {
                id: project.id.clone(),
                path: project.path.clone(),
                name: Some(project.name.clone()),
                default_provider: None,
                leading_branch: project.leading_branch.clone(),
                auto_reopen_agents: project.auto_reopen_agents,
                startup_command: project.startup_command.clone(),
                env: project.env.clone(),
            })
            .expect("seed project");
        let session = AgentSession {
            id: "session-1".to_string(),
            project_id: project.id.clone(),
            project_path: Some(project.path.clone()),
            provider: ProviderKind::from_str("codex"),
            source_branch: "main".to_string(),
            branch_name: "agent-branch".to_string(),
            worktree_path: paths.worktrees_root.to_string_lossy().to_string(),
            title: None,
            started_providers: Vec::new(),
            desired_running: false,
            auto_reopen_enabled: true,
            status: SessionStatus::Detached,
            created_at: now,
            updated_at: now,
        };
        let (worker_tx, worker_rx) = mpsc::channel();
        let single_instance_lock = crate::lockfile::SingleInstanceLock::acquire(&paths.lock_path)
            .expect("single-instance lock for test App");
        let mut app = App {
            config: Config::default(),
            paths,
            bindings,
            session_store,
            projects: vec![project],
            sessions: vec![session],
            staged_files: Vec::new(),
            unstaged_files: Vec::new(),
            selected_left: 0,
            left_section: crate::app::LeftSection::Projects,
            selected_terminal_index: 0,
            right_section: RightSection::Unstaged,
            files_index: 0,
            files_search: TextInput::new(),
            files_search_active: false,
            commit_input: TextInput::new()
                .with_multiline(4)
                .with_placeholder("Type your commit message\u{2026}"),
            show_diff_line_numbers: false,
            left_width_pct: 20,
            right_width_pct: 23,
            terminal_pane_height_pct: 35,
            staged_pane_height_pct: 50,
            commit_pane_height_pct: 40,
            focus: FocusPane::Left,
            center_mode: CenterMode::Agent,
            left_collapsed: false,
            right_collapsed: false,
            right_hidden: false,
            resize_mode: false,
            help_scroll: None,
            last_help_height: 0,
            last_help_lines: 0,
            fullscreen_overlay: FullscreenOverlay::None,
            startup_log_viewer: None,
            status: StatusLine::new("ready"),
            prompt: PromptState::None,
            input_target: InputTarget::None,
            session_surface: crate::model::SessionSurface::Agent,
            clipboard: Clipboard::new(),
            worker_tx,
            worker_rx,
            providers: std::collections::HashMap::new(),
            running_provider_pins: std::collections::HashMap::new(),
            companion_terminals: std::collections::HashMap::new(),
            active_terminal_id: None,
            terminal_return_to_list: false,
            terminal_counter: 0,
            create_agent_in_flight: false,
            agent_launches_in_flight: std::collections::HashSet::new(),
            pulls_in_flight: std::collections::HashSet::new(),
            resource_stats_in_flight: false,
            last_pty_size: (0, 0),
            last_pty_activity: std::collections::HashMap::new(),
            prev_scrollback_offset: 0,
            last_diff_height: 0,
            last_diff_visual_lines: 0,
            theme: Theme::default_dark(),
            tick_count: 0,
            start_time: std::time::Instant::now(),
            readonly_nudge_tick: None,
            watched_worktree: Arc::new(Mutex::new(None::<PathBuf>)),
            has_active_processes: Arc::new(AtomicBool::new(false)),
            collapsed_projects: std::collections::HashSet::new(),
            left_items_cache: Vec::new(),
            mouse_layout: MouseLayoutState::default(),
            overlay_layout: OverlayMouseLayoutState::default(),
            mouse_drag: None,
            last_mouse_click: None,
            pressed_button: None,
            interactive_patterns: crate::keybindings::InteractiveBytePatterns {
                bindings: Vec::new(),
            },
            raw_input_parser: crate::raw_input::RawInputParser::default(),
            raw_input_buf: Vec::new(),
            loading_input_buf: Vec::new(),
            in_bracket_paste: false,
            macro_bar: None,
            sigwinch_flag: Arc::new(AtomicBool::new(false)),
            force_redraw: false,
            welcome_tip_index: 0,
            welcome_logo_visible: false,
            welcome_logo_alt: false,
            welcome_tip_selection: usize::MAX,
            branch_sync_sessions: Arc::new(Mutex::new(Vec::new())),
            gh_status: crate::model::GhStatus::Unknown,
            github_integration_enabled: false,
            pr_banner_at_bottom: true,
            pr_statuses: std::collections::HashMap::new(),
            pr_sync_sessions: Arc::new(Mutex::new(Vec::new())),
            pr_sync_enabled: Arc::new(AtomicBool::new(false)),
            pr_last_checked: std::collections::HashMap::new(),
            refs_watcher: None,
            refs_watch_paths: std::collections::HashMap::new(),
            resume_fallback_candidates: std::collections::HashMap::new(),
            pending_deletions: std::collections::HashSet::new(),
            deletion_busy_messages: std::collections::HashMap::new(),
            syntax_cache: crate::diff::SyntaxCache::new(),
            snapshot_buf: crate::pty::TerminalSnapshot::empty(),
            last_snapshot_id: None,
            terminal_selection: None,
            startup_log_selection: None,
            _single_instance_lock: single_instance_lock,
        };
        app.interactive_patterns = app.bindings.interactive_byte_patterns();
        app.rebuild_left_items();
        app.selected_left = 1;
        app
    }

    fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind,
            column,
            row,
            modifiers: KeyModifiers::NONE,
        }
    }

    fn install_mouse_layout(app: &mut App) {
        app.mouse_layout = MouseLayoutState {
            body: Rect::new(0, 0, 100, 20),
            left: Rect::new(0, 0, 20, 20),
            center: Rect::new(20, 0, 57, 20),
            right: Rect::new(77, 0, 23, 20),
            left_list: Rect::new(1, 1, 18, 10),
            terminal_list: Rect::default(),
            agent_term: Some(Rect::new(21, 1, 55, 16)),
            unstaged_list: Some(Rect::new(78, 1, 21, 8)),
            staged_list: Some(Rect::new(78, 9, 21, 5)),
            commit_area: Some(Rect::new(77, 14, 23, 6)),
            commit_text_area: Some(Rect::new(78, 15, 21, 4)),
        };
    }

    fn install_command_overlay(app: &mut App, items: usize) {
        app.overlay_layout.active = OverlayMouseLayout::Command {
            input: Rect::new(15, 7, 70, 1),
            list: Rect::new(15, 9, 70, 6),
            items,
            offset: 0,
        };
    }

    fn install_browser_overlay(app: &mut App, items: usize) {
        app.overlay_layout.active = OverlayMouseLayout::BrowseProjects {
            input: Some(Rect::new(15, 4, 70, 1)),
            list: Rect::new(15, 6, 70, 8),
            items,
            offset: 0,
        };
    }

    fn install_pick_editor_overlay(app: &mut App, items: usize) {
        app.overlay_layout.active = OverlayMouseLayout::PickEditor {
            list: Rect::new(19, 8, 62, 6),
            items,
            offset: 0,
        };
    }

    fn install_change_theme_overlay(app: &mut App, items: usize) {
        app.overlay_layout.active = OverlayMouseLayout::ChangeTheme {
            list: Rect::new(11, 9, 58, 10),
            items,
            offset: 0,
        };
    }

    fn install_configure_startup_command_overlay(app: &mut App) {
        app.overlay_layout.active = OverlayMouseLayout::ConfigureStartupCommand {
            input: Rect::new(10, 6, 40, 4),
        };
    }

    fn drain_until(app: &mut App, mut done: impl FnMut(&App) -> bool) {
        for _ in 0..50 {
            app.drain_events();
            if done(app) {
                return;
            }
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
        app.drain_events();
    }

    #[test]
    fn reload_config_command_applies_valid_config_immediately_after_worker_completes() {
        let mut app = test_app(default_bindings());
        let mut config = Config::default();
        config.ui.right_width_pct = 37;
        config.ui.show_diff_line_numbers = true;
        let bindings = RuntimeBindings::from_keys_config(&config.keys);
        std::fs::write(
            &app.paths.config_path,
            crate::config::render_config_with(&config, &bindings),
        )
        .expect("write valid config");

        app.execute_command("reload-config".to_string())
            .expect("start reload config");
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Busy);
        assert_eq!(app.status.message(), "Reloading config.toml.");

        drain_until(&mut app, |app| app.config.ui.right_width_pct == 37);

        assert_eq!(app.right_width_pct, 37);
        assert!(app.show_diff_line_numbers);
        assert!(matches!(app.prompt, PromptState::None));
        assert_eq!(
            app.status.message(),
            "Configuration reloaded. New settings are active now."
        );
    }

    #[test]
    fn reload_config_command_keeps_current_config_and_opens_modal_on_validation_error() {
        let mut app = test_app(default_bindings());
        app.config.ui.right_width_pct = 41;
        std::fs::write(
            &app.paths.config_path,
            r#"
[keys]
show_terminal_keys = true
not_a_real_action = ["x"]
"#,
        )
        .expect("write invalid config");

        app.execute_command("reload-config".to_string())
            .expect("start reload config");
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Busy);

        drain_until(&mut app, |app| {
            matches!(app.prompt, PromptState::ConfigReloadFailed { .. })
        });

        assert_eq!(app.config.ui.right_width_pct, 41);
        assert_eq!(
            app.status.message(),
            "Config reload failed. Review the modal before retrying."
        );
        match &app.prompt {
            PromptState::ConfigReloadFailed {
                error,
                recover_old_config,
                focus,
            } => {
                assert!(error.contains("unknown action"));
                assert!(!recover_old_config);
                assert_eq!(*focus, ConfigReloadFailedFocus::Close);
            }
            other => panic!("expected config reload failure prompt, got {other:?}"),
        }
    }

    #[test]
    fn reload_config_failure_can_recover_last_working_config_file_async() {
        let mut app = test_app(default_bindings());
        app.config.ui.right_width_pct = 42;
        std::fs::write(&app.paths.config_path, "this is not valid toml")
            .expect("write broken config");
        app.prompt = PromptState::ConfigReloadFailed {
            error: "broken".to_string(),
            recover_old_config: true,
            focus: ConfigReloadFailedFocus::Apply,
        };

        app.resolve_config_reload_failed(true);
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Busy);
        assert_eq!(
            app.status.message(),
            "Restoring the last working config.toml."
        );

        drain_until(&mut app, |app| {
            app.status.message() == "Restored the last working configuration to config.toml."
        });

        assert!(matches!(app.prompt, PromptState::None));
        let recovered = std::fs::read_to_string(&app.paths.config_path).expect("read recovered");
        let parsed: Config = toml::from_str(&recovered).expect("parse recovered config");
        assert_eq!(parsed.ui.right_width_pct, 42);
    }

    fn install_kill_running_overlay(app: &mut App, items: usize) {
        app.overlay_layout.active = OverlayMouseLayout::KillRunning {
            input: Some(Rect::new(12, 4, 70, 1)),
            list: Rect::new(12, 6, 70, 8),
            items,
            offset: 0,
            cancel_button: Rect::new(12, 16, 14, 3),
            hovered_button: Rect::new(28, 16, 16, 3),
            selected_button: Rect::new(46, 16, 17, 3),
            visible_button: Rect::new(65, 16, 15, 3),
        };
    }

    fn install_resource_monitor_overlay(app: &mut App, items: usize) {
        app.overlay_layout.active = OverlayMouseLayout::ResourceMonitor {
            list: Rect::new(11, 6, 58, 8),
            items,
            offset: 0,
        };
    }

    fn install_startup_command_logs_overlay(app: &mut App, items: usize) {
        app.overlay_layout.active = OverlayMouseLayout::StartupCommandLogs {
            area: Rect::new(10, 3, 80, 18),
            list: Rect::new(12, 5, 30, 12),
            body: Rect::new(44, 5, 40, 12),
            items,
            offset: 0,
            close_button: Rect::new(42, 18, 16, 3),
        };
    }

    fn startup_command_logs_prompt() -> StartupCommandLogPrompt {
        StartupCommandLogPrompt {
            scope_label: "demo".to_string(),
            entries: vec![
                crate::startup::StartupCommandLogEntry {
                    path: PathBuf::from("/tmp/startup-command.log"),
                    display_name: "startup-command.log".to_string(),
                    modified_at: None,
                },
                crate::startup::StartupCommandLogEntry {
                    path: PathBuf::from("/tmp/install.log"),
                    display_name: "install.log".to_string(),
                    modified_at: None,
                },
            ],
            selected: 0,
            filter: TextInput::new(),
            searching: false,
            content: (0..30)
                .map(|line| format!("startup command output line {line}"))
                .collect::<Vec<_>>()
                .join("\n"),
            scroll_offset: 0,
        }
    }

    fn install_confirm_delete_overlay(app: &mut App) {
        app.overlay_layout.active = OverlayMouseLayout::ConfirmDeleteAgent {
            cancel_button: Rect::new(34, 10, 16, 3),
            delete_button: Rect::new(52, 10, 16, 3),
            checkbox: Some(OverlayCheckbox {
                id: OverlayCheckboxId::DeleteAgentWorktree,
                rect: Rect::new(24, 7, 44, 1),
            }),
        };
    }

    fn install_confirm_quit_overlay(app: &mut App) {
        app.overlay_layout.active = OverlayMouseLayout::ConfirmQuit {
            cancel_button: Rect::new(34, 10, 16, 3),
            quit_button: Rect::new(52, 10, 16, 3),
        };
    }

    fn install_confirm_kill_running_overlay(app: &mut App) {
        app.overlay_layout.active = OverlayMouseLayout::ConfirmKillRunning {
            cancel_button: Rect::new(34, 10, 16, 3),
            kill_button: Rect::new(52, 10, 16, 3),
        };
    }

    fn confirm_kill_running_prompt() -> ConfirmKillRunningPrompt {
        let runtime_id = RuntimeTargetId::Agent("session-1".to_string());
        ConfirmKillRunningPrompt {
            previous: KillRunningPrompt {
                runtimes: vec![sample_runtime(
                    runtime_id.clone(),
                    KillableRuntimeKind::Agent,
                    "agent-branch",
                    "demo / codex / agent-branch",
                )],
                filter: TextInput::new(),
                searching: false,
                hovered_visible_index: 0,
                selected_ids: std::iter::once(runtime_id.clone()).collect(),
                focus: KillRunningFocus::Footer(KillRunningFooterAction::Selected),
            },
            action: KillRunningAction::Selected,
            target_ids: vec![runtime_id],
            confirm_selected: true,
        }
    }

    fn install_confirm_discard_overlay(app: &mut App) {
        app.overlay_layout.active = OverlayMouseLayout::ConfirmDiscardFile {
            cancel_button: Rect::new(34, 10, 16, 3),
            discard_button: Rect::new(52, 10, 16, 3),
        };
    }

    fn install_rename_overlay(app: &mut App) {
        app.overlay_layout.active = OverlayMouseLayout::RenameSession {
            input: Rect::new(24, 10, 30, 1),
            checkbox: Some(OverlayCheckbox {
                id: OverlayCheckboxId::RenameSessionBranch,
                rect: Rect::new(24, 12, 34, 2),
            }),
        };
    }

    fn install_name_new_agent_overlay(app: &mut App) {
        app.overlay_layout.active = OverlayMouseLayout::NameNewAgent {
            input: Rect::new(24, 10, 30, 1),
            checkbox: Some(OverlayCheckbox {
                id: OverlayCheckboxId::NameNewAgentRandomizedPetName,
                rect: Rect::new(24, 12, 34, 2),
            }),
        };
    }

    fn clipboard_ok(_: &str) -> anyhow::Result<()> {
        Ok(())
    }

    fn clipboard_fail(_: &str) -> anyhow::Result<()> {
        Err(anyhow::anyhow!("linux clipboard unavailable"))
    }

    fn sample_runtime(
        id: RuntimeTargetId,
        kind: KillableRuntimeKind,
        label: &str,
        context: &str,
    ) -> KillableRuntime {
        KillableRuntime {
            id,
            kind,
            label: label.to_string(),
            context: context.to_string(),
            search_text: format!("{label} {context}"),
        }
    }

    fn init_git_repo_with_modified_file(
        app: &App,
        relative_path: &str,
        original: &str,
        updated: &str,
    ) {
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        std::fs::create_dir_all(worktree).expect("worktree dir");

        Command::new("git")
            .args(["init"])
            .current_dir(worktree)
            .output()
            .expect("git init");
        Command::new("git")
            .args(["config", "user.email", "test@example.com"])
            .current_dir(worktree)
            .output()
            .expect("git email");
        Command::new("git")
            .args(["config", "user.name", "Test User"])
            .current_dir(worktree)
            .output()
            .expect("git name");

        let file_path = worktree.join(relative_path);
        if let Some(parent) = file_path.parent() {
            std::fs::create_dir_all(parent).expect("file parent");
        }
        std::fs::write(&file_path, original).expect("write original");
        Command::new("git")
            .args(["add", relative_path])
            .current_dir(worktree)
            .output()
            .expect("git add");
        Command::new("git")
            .args(["commit", "-m", "initial"])
            .current_dir(worktree)
            .output()
            .expect("git commit");

        std::fs::write(&file_path, updated).expect("write updated");
    }

    #[test]
    fn refresh_selected_project_blocks_repeat_presses_while_pull_is_running() {
        let mut app = test_app(default_bindings());
        app.selected_left = 0;

        app.refresh_selected_project()
            .expect("start project refresh");
        app.refresh_selected_project()
            .expect("repeat refresh should not error");

        let repo_path = app.projects[0].path.clone();
        assert!(app.pulls_in_flight.contains(&repo_path));
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Warning);
        assert!(app.status.text().contains("already in progress"));
    }

    #[test]
    fn project_pull_completion_clears_in_flight_guard() {
        let mut app = test_app(default_bindings());
        let repo_path = app.projects[0].path.clone();
        app.pulls_in_flight.insert(repo_path.clone());

        app.worker_tx
            .send(WorkerEvent::PullCompleted {
                repo_path,
                target: PullTarget::Project {
                    project_id: app.projects[0].id.clone(),
                    project_name: app.projects[0].name.clone(),
                    leading_branch: app.projects[0].leading_branch.clone(),
                },
                result: Ok(Some("feature/demo".to_string())),
            })
            .expect("queue worker event");

        app.drain_events();

        assert!(app.pulls_in_flight.is_empty());
        assert_eq!(app.projects[0].current_branch, "feature/demo");
        assert_eq!(
            app.projects[0].branch_status,
            ProjectBranchStatus::NotLeading
        );
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Info);
    }

    #[test]
    fn pull_from_remote_blocks_repeat_presses_while_pull_is_running() {
        let mut app = test_app(default_bindings());
        app.selected_left = 1;

        app.pull_from_remote().expect("start session pull");
        app.pull_from_remote()
            .expect("repeat pull should not error");

        let repo_path = app.sessions[0].worktree_path.clone();
        assert!(app.pulls_in_flight.contains(&repo_path));
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Warning);
        assert!(app.status.text().contains("already in progress"));
    }

    #[test]
    fn rename_session_prompt_accepts_text_before_agent_input() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::RenameSession {
            session_id: "session-1".to_string(),
            input: TextInput::with_text("agent".to_string()),
            rename_branch: false,
        };
        app.input_target = InputTarget::Agent;

        app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::RenameSession { input, .. } => {
                assert_eq!(input.text, "agentx");
                assert_eq!(input.cursor, 6);
            }
            other => panic!("expected rename prompt, got {other:?}"),
        }
    }

    #[test]
    fn rename_session_text_ignores_printable_close_overlay_binding() {
        let mut app = test_app(bindings_with_overrides(&[(Action::CloseOverlay, &["x"])]));
        app.prompt = PromptState::RenameSession {
            session_id: "session-1".to_string(),
            input: TextInput::with_text("agent".to_string()),
            rename_branch: false,
        };

        app.handle_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::RenameSession { input, .. } => {
                assert_eq!(input.text, "agentx");
                assert_eq!(input.cursor, 6);
            }
            other => panic!("expected rename prompt, got {other:?}"),
        }
    }

    #[test]
    fn rename_session_uses_custom_dialog_confirm_binding() {
        let mut app = test_app(bindings_with_overrides(&[(Action::Confirm, &["tab"])]));
        app.prompt = PromptState::RenameSession {
            session_id: "session-1".to_string(),
            input: TextInput::with_text("agent-branch".to_string()),
            rename_branch: false,
        };

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();

        assert!(matches!(app.prompt, PromptState::None));
        assert_eq!(
            app.sessions[0].title.as_deref(),
            Some("agent-branch"),
            "custom confirm binding should apply the rename"
        );
    }

    #[test]
    fn open_rename_session_clears_interactive_target() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;

        app.open_rename_session().unwrap();

        assert!(matches!(app.prompt, PromptState::RenameSession { .. }));
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
    }

    #[test]
    fn configure_startup_command_edit_mode_keeps_enter_in_input() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfigureStartupCommand {
            project_id: "project-1".to_string(),
            project_name: "demo".to_string(),
            input: TextInput::with_text("npm install".to_string()).with_multiline(6),
        };
        app.input_target = InputTarget::StartupCommand;

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::ConfigureStartupCommand { input, .. } => {
                assert_eq!(input.text, "npm install\nn");
            }
            other => panic!("expected configure startup command prompt, got {other:?}"),
        }
        assert_eq!(app.input_target, InputTarget::StartupCommand);
    }

    #[test]
    fn configure_startup_command_escape_exits_edit_mode_only() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfigureStartupCommand {
            project_id: "project-1".to_string(),
            project_name: "demo".to_string(),
            input: TextInput::with_text("npm install".to_string()).with_multiline(6),
        };
        app.input_target = InputTarget::StartupCommand;

        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .unwrap();

        assert!(matches!(
            app.prompt,
            PromptState::ConfigureStartupCommand { .. }
        ));
        assert_eq!(app.input_target, InputTarget::None);
    }

    #[test]
    fn configure_startup_command_ctrl_d_clears_input() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfigureStartupCommand {
            project_id: "project-1".to_string(),
            project_name: "demo".to_string(),
            input: TextInput::with_text("npm install".to_string()).with_multiline(6),
        };

        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL))
            .unwrap();

        match &app.prompt {
            PromptState::ConfigureStartupCommand { input, .. } => {
                assert_eq!(input.text, "");
                assert_eq!(input.cursor, 0);
            }
            other => panic!("expected configure startup command prompt, got {other:?}"),
        }
    }

    #[test]
    fn configure_startup_command_double_click_enters_edit_mode() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfigureStartupCommand {
            project_id: "project-1".to_string(),
            project_name: "demo".to_string(),
            input: TextInput::with_text("npm install\nnpm test".to_string()).with_multiline(6),
        };
        install_configure_startup_command_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 14, 7));
        assert_eq!(app.input_target, InputTarget::None);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 14, 7));

        assert_eq!(app.input_target, InputTarget::StartupCommand);
        match &app.prompt {
            PromptState::ConfigureStartupCommand { input, .. } => {
                assert!(input.cursor > 0);
            }
            other => panic!("expected configure startup command prompt, got {other:?}"),
        }
    }

    #[test]
    fn configure_startup_command_empty_non_editing_shows_themed_placeholder() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfigureStartupCommand {
            project_id: "project-1".to_string(),
            project_name: "demo".to_string(),
            input: TextInput::new()
                .with_multiline(6)
                .with_placeholder("Enter startup command..."),
        };
        app.input_target = InputTarget::None;

        let backend = TestBackend::new(100, 30);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");

        let buffer = terminal.backend().buffer();
        let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
        assert!(
            rendered.contains("Enter startup command..."),
            "expected placeholder in non-editing mode, got: {rendered}"
        );

        let OverlayMouseLayout::ConfigureStartupCommand { input } = app.overlay_layout.active
        else {
            panic!("expected configure startup command overlay layout");
        };
        let placeholder_cell = (input.y..input.y + input.height)
            .flat_map(|y| (input.x..input.x + input.width).map(move |x| (x, y)))
            .find_map(|pos| {
                let cell = buffer.cell(pos)?;
                (cell.symbol() == "E").then_some(cell)
            })
            .expect("placeholder first cell");
        assert_eq!(placeholder_cell.fg, app.theme.hint_desc_fg);
    }

    #[test]
    fn rename_title_only_does_not_change_branch_name() {
        let mut app = test_app(default_bindings());
        let original_branch = app.sessions[0].branch_name.clone();

        app.apply_rename_session("session-1", "new-title".to_string(), false);

        assert_eq!(
            app.sessions[0].title.as_deref(),
            Some("new-title"),
            "title should be updated"
        );
        assert_eq!(
            app.sessions[0].branch_name, original_branch,
            "branch_name should remain unchanged when rename_branch is false"
        );
    }

    #[test]
    fn rename_toggle_checkbox_flips_rename_branch() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::RenameSession {
            session_id: "session-1".to_string(),
            input: TextInput::with_text("test".to_string()),
            rename_branch: true,
        };

        // Tab toggles the checkbox.
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::RenameSession { rename_branch, .. } => {
                assert!(!*rename_branch, "Tab should toggle rename_branch to false");
            }
            other => panic!("expected RenameSession, got {other:?}"),
        }

        // Tab again toggles it back.
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::RenameSession { rename_branch, .. } => {
                assert!(
                    *rename_branch,
                    "second Tab should toggle rename_branch back to true"
                );
            }
            other => panic!("expected RenameSession, got {other:?}"),
        }
    }

    #[test]
    fn rename_shift_tab_toggles_checkbox() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::RenameSession {
            session_id: "session-1".to_string(),
            input: TextInput::with_text("test".to_string()),
            rename_branch: true,
        };

        app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::RenameSession { rename_branch, .. } => {
                assert!(
                    !*rename_branch,
                    "Shift-Tab should toggle rename_branch to false"
                );
            }
            other => panic!("expected RenameSession, got {other:?}"),
        }
    }

    #[test]
    fn open_rename_session_initializes_rename_branch_true() {
        let mut app = test_app(default_bindings());

        app.open_rename_session().unwrap();

        match &app.prompt {
            PromptState::RenameSession { rename_branch, .. } => {
                assert!(*rename_branch, "rename_branch should default to true");
            }
            other => panic!("expected RenameSession, got {other:?}"),
        }
    }

    #[test]
    fn open_kill_running_requires_live_runtimes() {
        let mut app = test_app(default_bindings());

        app.open_kill_running().unwrap();

        assert!(matches!(app.prompt, PromptState::None));
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Error);
        assert!(app.status.text().contains("No running agents"));
    }

    #[test]
    fn open_kill_running_snapshots_agents_and_terminals() {
        let mut app = test_app(default_bindings());
        let worktree_path = app.sessions[0].worktree_path.clone();
        let worktree = std::path::Path::new(&worktree_path);
        let args = vec!["-c".to_string(), "sleep 5".to_string()];
        app.providers.insert(
            app.sessions[0].id.clone(),
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn agent"),
        );
        app.companion_terminals.insert(
            "term-1".to_string(),
            crate::app::CompanionTerminal {
                session_id: app.sessions[0].id.clone(),
                label: "shell".to_string(),
                foreground_cmd: Some("python".to_string()),
                client: PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000)
                    .expect("spawn terminal"),
            },
        );

        app.open_kill_running().unwrap();

        match &app.prompt {
            PromptState::KillRunning(prompt) => {
                assert_eq!(prompt.runtimes.len(), 2);
                let agent = prompt
                    .runtimes
                    .iter()
                    .find(|runtime| matches!(runtime.id, RuntimeTargetId::Agent(_)))
                    .expect("agent runtime");
                assert_eq!(agent.label, "Codex");
                assert_eq!(
                    agent.context,
                    "on agent \"agent-branch\" under project \"demo\""
                );

                let terminal = prompt
                    .runtimes
                    .iter()
                    .find(|runtime| matches!(runtime.id, RuntimeTargetId::Terminal(_)))
                    .expect("terminal runtime");
                assert_eq!(terminal.label, "python");
                assert_eq!(
                    terminal.context,
                    "on agent \"agent-branch\" under project \"demo\""
                );
            }
            other => panic!("expected kill-running prompt, got {other:?}"),
        }
    }

    #[test]
    fn kill_running_terminal_label_deduplicates_term_prefix() {
        let mut app = test_app(default_bindings());
        let worktree_path = app.sessions[0].worktree_path.clone();
        let worktree = std::path::Path::new(&worktree_path);
        let args = vec!["-c".to_string(), "sleep 5".to_string()];
        app.companion_terminals.insert(
            "term-1".to_string(),
            crate::app::CompanionTerminal {
                session_id: app.sessions[0].id.clone(),
                label: "shell".to_string(),
                foreground_cmd: Some("TERM sleep".to_string()),
                client: PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000)
                    .expect("spawn terminal"),
            },
        );

        let runtimes = app.running_runtime_snapshot();
        let terminal = runtimes
            .iter()
            .find(|runtime| matches!(runtime.id, RuntimeTargetId::Terminal(_)))
            .expect("terminal runtime");
        assert_eq!(terminal.label, "sleep");
    }

    #[test]
    fn kill_running_tab_focus_reaches_cancel_button() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::KillRunning(KillRunningPrompt {
            runtimes: vec![sample_runtime(
                RuntimeTargetId::Agent("session-1".to_string()),
                KillableRuntimeKind::Agent,
                "Codex",
                "on agent \"agent-branch\" under project \"demo\"",
            )],
            filter: TextInput::new(),
            searching: false,
            hovered_visible_index: 0,
            selected_ids: std::collections::HashSet::new(),
            focus: KillRunningFocus::List,
        });

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::KillRunning(prompt) => {
                assert_eq!(
                    prompt.focus,
                    KillRunningFocus::Footer(KillRunningFooterAction::Cancel)
                )
            }
            other => panic!("expected kill-running prompt, got {other:?}"),
        }
    }

    #[test]
    fn kill_running_footer_skips_kill_selected_when_nothing_is_marked() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::KillRunning(KillRunningPrompt {
            runtimes: vec![sample_runtime(
                RuntimeTargetId::Agent("session-1".to_string()),
                KillableRuntimeKind::Agent,
                "Codex",
                "on agent \"agent-branch\" under project \"demo\"",
            )],
            filter: TextInput::new(),
            searching: false,
            hovered_visible_index: 0,
            selected_ids: std::collections::HashSet::new(),
            focus: KillRunningFocus::Footer(KillRunningFooterAction::Hovered),
        });

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::KillRunning(prompt) => {
                assert_eq!(
                    prompt.focus,
                    KillRunningFocus::Footer(KillRunningFooterAction::Visible)
                )
            }
            other => panic!("expected kill-running prompt, got {other:?}"),
        }
    }

    #[test]
    fn kill_running_search_keeps_hidden_selection() {
        let mut app = test_app(default_bindings());
        let selected_id = RuntimeTargetId::Agent("session-1".to_string());
        app.prompt = PromptState::KillRunning(KillRunningPrompt {
            runtimes: vec![
                sample_runtime(
                    selected_id.clone(),
                    KillableRuntimeKind::Agent,
                    "alpha-agent",
                    "demo / codex / alpha-agent",
                ),
                sample_runtime(
                    RuntimeTargetId::Terminal("term-1".to_string()),
                    KillableRuntimeKind::Terminal,
                    "beta-shell",
                    "demo / alpha-agent",
                ),
            ],
            filter: TextInput::new(),
            searching: false,
            hovered_visible_index: 0,
            selected_ids: std::iter::once(selected_id.clone()).collect(),
            focus: KillRunningFocus::List,
        });

        app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
            .unwrap();
        for ch in ['b', 'e', 't', 'a'] {
            app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
                .unwrap();
        }
        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        let PromptState::KillRunning(prompt) = &app.prompt else {
            panic!("expected kill-running prompt");
        };
        let visible = App::visible_kill_running_indices(prompt);
        assert_eq!(visible.len(), 1);
        assert!(prompt.selected_ids.contains(&selected_id));

        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .unwrap();
        let PromptState::KillRunning(prompt) = &app.prompt else {
            panic!("expected kill-running prompt");
        };
        assert!(prompt.selected_ids.contains(&selected_id));
    }

    #[test]
    fn confirm_kill_cancel_restores_selection_modal_state() {
        let mut app = test_app(default_bindings());
        let selected_id = RuntimeTargetId::Agent("session-1".to_string());
        let previous = KillRunningPrompt {
            runtimes: vec![sample_runtime(
                selected_id.clone(),
                KillableRuntimeKind::Agent,
                "agent-branch",
                "demo / codex / agent-branch",
            )],
            filter: TextInput::with_text("agent".to_string()),
            searching: false,
            hovered_visible_index: 0,
            selected_ids: std::iter::once(selected_id).collect(),
            focus: KillRunningFocus::Footer(KillRunningFooterAction::Selected),
        };
        app.prompt = PromptState::ConfirmKillRunning(ConfirmKillRunningPrompt {
            previous: previous.clone(),
            action: KillRunningAction::Selected,
            target_ids: vec![RuntimeTargetId::Agent("session-1".to_string())],
            confirm_selected: false,
        });

        app.resolve_confirm_kill_running(false);

        match &app.prompt {
            PromptState::KillRunning(prompt) => {
                assert_eq!(prompt.filter.text, previous.filter.text);
                assert_eq!(prompt.selected_ids, previous.selected_ids);
                assert_eq!(prompt.focus, previous.focus);
            }
            other => panic!("expected restored kill-running prompt, got {other:?}"),
        }
    }

    #[test]
    fn kill_visible_only_kills_filtered_rows() {
        let mut app = test_app(default_bindings());
        let worktree_path = app.sessions[0].worktree_path.clone();
        let worktree = std::path::Path::new(&worktree_path);
        std::fs::create_dir_all(app.paths.worktrees_root.join("other")).expect("other worktree");
        let now = Utc::now();
        app.sessions.push(AgentSession {
            id: "session-2".to_string(),
            project_id: app.projects[0].id.clone(),
            project_path: Some(app.projects[0].path.clone()),
            provider: ProviderKind::from_str("claude"),
            source_branch: "main".to_string(),
            branch_name: "beta-agent".to_string(),
            worktree_path: app.paths.worktrees_root.join("other").display().to_string(),
            title: None,
            started_providers: Vec::new(),
            desired_running: false,
            auto_reopen_enabled: true,
            status: SessionStatus::Detached,
            created_at: now,
            updated_at: now,
        });
        let args = vec!["-c".to_string(), "sleep 5".to_string()];
        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn first"),
        );
        app.providers.insert(
            "session-2".to_string(),
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn second"),
        );

        app.prompt = PromptState::KillRunning(KillRunningPrompt {
            runtimes: app.running_runtime_snapshot(),
            filter: TextInput::with_text("beta".to_string()),
            searching: false,
            hovered_visible_index: 0,
            selected_ids: std::iter::once(RuntimeTargetId::Agent("session-1".to_string()))
                .collect(),
            focus: KillRunningFocus::Footer(KillRunningFooterAction::Visible),
        });

        app.open_confirm_kill_running_action(KillRunningAction::Visible)
            .unwrap();
        app.resolve_confirm_kill_running(true);

        assert!(app.providers.contains_key("session-1"));
        assert!(!app.providers.contains_key("session-2"));
    }

    #[test]
    fn kill_selected_removes_running_targets_and_resets_terminal_surface() {
        let mut app = test_app(default_bindings());
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let args = vec!["-c".to_string(), "sleep 5".to_string()];
        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn agent"),
        );
        app.companion_terminals.insert(
            "term-1".to_string(),
            crate::app::CompanionTerminal {
                session_id: app.sessions[0].id.clone(),
                label: "shell".to_string(),
                foreground_cmd: None,
                client: PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000)
                    .expect("spawn terminal"),
            },
        );
        app.active_terminal_id = Some("term-1".to_string());
        app.session_surface = SessionSurface::Terminal;
        app.input_target = InputTarget::None;
        app.fullscreen_overlay = FullscreenOverlay::Terminal;

        app.prompt = PromptState::KillRunning(KillRunningPrompt {
            runtimes: app.running_runtime_snapshot(),
            filter: TextInput::new(),
            searching: false,
            hovered_visible_index: 0,
            selected_ids: [
                RuntimeTargetId::Agent("session-1".to_string()),
                RuntimeTargetId::Terminal("term-1".to_string()),
            ]
            .into_iter()
            .collect(),
            focus: KillRunningFocus::Footer(KillRunningFooterAction::Selected),
        });

        app.open_confirm_kill_running_action(KillRunningAction::Selected)
            .unwrap();
        app.resolve_confirm_kill_running(true);

        assert!(app.providers.is_empty());
        assert!(app.companion_terminals.is_empty());
        assert_eq!(app.session_surface, SessionSurface::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        assert!(matches!(app.prompt, PromptState::None));
    }

    #[test]
    fn kill_selected_warns_when_targets_are_already_gone() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(ConfirmKillRunningPrompt {
            previous: KillRunningPrompt {
                runtimes: vec![sample_runtime(
                    RuntimeTargetId::Agent("session-1".to_string()),
                    KillableRuntimeKind::Agent,
                    "agent-branch",
                    "demo / codex / agent-branch",
                )],
                filter: TextInput::new(),
                searching: false,
                hovered_visible_index: 0,
                selected_ids: std::iter::once(RuntimeTargetId::Agent("session-1".to_string()))
                    .collect(),
                focus: KillRunningFocus::Footer(KillRunningFooterAction::Selected),
            },
            action: KillRunningAction::Selected,
            target_ids: vec![RuntimeTargetId::Agent("session-1".to_string())],
            confirm_selected: true,
        });

        app.resolve_confirm_kill_running(true);

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Warning);
        assert!(app.status.text().contains("already gone"));
    }

    #[test]
    fn fork_selected_session_sets_busy_state_and_keeps_fresh_create_flow() {
        let mut app = test_app(default_bindings());

        app.fork_selected_session().unwrap();

        match &app.prompt {
            PromptState::NameNewAgent {
                request,
                input,
                randomize_name,
                focus,
                ..
            } => {
                assert!(matches!(request, CreateAgentRequest::ForkSession { .. }));
                assert!(input.text.is_empty());
                assert!(!*randomize_name);
                assert_eq!(*focus, NameNewAgentFocus::Input);
            }
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        }
        assert!(!app.create_agent_in_flight);
    }

    #[test]
    fn create_agent_always_opens_name_prompt_empty_by_default() {
        let mut app = test_app(default_bindings());

        app.create_agent_for_selected_project().unwrap();
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Busy);
        complete_create_agent_branch_inspection(&mut app, "main", "main");

        match &app.prompt {
            PromptState::NameNewAgent {
                request,
                input,
                randomize_name,
                focus,
                ..
            } => {
                assert!(matches!(request, CreateAgentRequest::NewProject { .. }));
                assert!(input.text.is_empty());
                assert!(!*randomize_name);
                assert_eq!(*focus, NameNewAgentFocus::Input);
            }
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        }
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Info);
        assert_eq!(
            app.status.message(),
            "Branch check complete for \"demo\". Confirm or edit the agent name to continue."
        );
    }

    #[test]
    fn create_agent_known_non_default_branch_opens_name_prompt_with_leading_branch() {
        let mut app = test_app(default_bindings());
        let repo_path = PathBuf::from(&app.projects[0].path);
        set_remote_default(&repo_path, "main");
        run_git(&repo_path, &["switch", "-c", "feature"]);

        app.create_agent_for_selected_project().unwrap();
        complete_create_agent_branch_inspection(&mut app, "feature", "main");

        match &app.prompt {
            PromptState::NameNewAgent { request, .. } => match request {
                CreateAgentRequest::NewProject { project, .. } => {
                    assert_eq!(project.current_branch, "feature");
                    assert_eq!(project.leading_branch.as_deref(), Some("main"));
                }
                other => panic!("expected new project request, got {other:?}"),
            },
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        }
    }

    #[test]
    fn create_agent_heuristic_feature_opens_name_prompt_with_stored_branch() {
        let mut app = test_app(default_bindings());
        let repo_path = PathBuf::from(&app.projects[0].path);
        run_git(&repo_path, &["switch", "-c", "feature"]);

        app.create_agent_for_selected_project().unwrap();
        complete_create_agent_branch_inspection(&mut app, "feature", "main");

        assert!(matches!(app.prompt, PromptState::NameNewAgent { .. }));
    }

    #[test]
    fn create_agent_unknown_default_on_main_opens_name_prompt() {
        let mut app = test_app(default_bindings());

        app.create_agent_for_selected_project().unwrap();
        complete_create_agent_branch_inspection(&mut app, "main", "main");

        assert!(matches!(app.prompt, PromptState::NameNewAgent { .. }));
    }

    #[test]
    fn new_project_agent_existing_branch_opens_use_existing_prompt() {
        let mut app = test_app(default_bindings());
        let repo_path = PathBuf::from(&app.projects[0].path);
        run_git(&repo_path, &["branch", "reuse-me"]);

        app.prompt = PromptState::NameNewAgent {
            request: CreateAgentRequest::NewProject {
                project: app.projects[0].clone(),
                custom_name: None,
                use_existing_branch: false,
                pull_before_create: false,
            },
            input: TextInput::with_text("reuse-me".to_string()),
            randomize_name: false,
            randomized_name: None,
            focus: NameNewAgentFocus::Input,
        };

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmUseExistingBranch {
                request,
                branch_name,
                location,
                confirm_selected,
            } => {
                assert!(matches!(request, CreateAgentRequest::NewProject { .. }));
                assert_eq!(branch_name, "reuse-me");
                assert_eq!(*location, crate::git::BranchLocation::Local);
                assert!(!*confirm_selected);
            }
            other => panic!("expected use-existing-branch prompt, got {other:?}"),
        }
    }

    #[test]
    fn pull_request_agent_existing_branch_skips_use_existing_prompt() {
        let mut app = test_app(default_bindings());
        let repo_path = PathBuf::from(&app.projects[0].path);
        run_git(&repo_path, &["branch", "feature/pr-42"]);
        app.create_agent_in_flight = true;

        app.prompt = PromptState::NameNewAgent {
            request: CreateAgentRequest::PullRequest {
                project: app.projects[0].clone(),
                host: "github.com".to_string(),
                owner_repo: "octocat/Hello-World".to_string(),
                number: 42,
                title: "Fix issue".to_string(),
                state: "OPEN".to_string(),
                head_branch: "feature/pr-42".to_string(),
                custom_name: Some("feature/pr-42".to_string()),
                use_existing_branch: false,
            },
            input: TextInput::with_text("feature/pr-42".to_string()),
            randomize_name: false,
            randomized_name: None,
            focus: NameNewAgentFocus::Input,
        };

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        assert!(matches!(app.prompt, PromptState::None));
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Error);
        assert!(
            app.status
                .text()
                .contains("An agent is already being created or forked.")
        );
    }

    #[test]
    fn create_agent_missing_leading_branch_sets_error_without_name_prompt() {
        let mut app = test_app(default_bindings());
        app.projects[0].leading_branch = Some("missing-main".to_string());

        app.create_agent_for_selected_project().unwrap();
        drain_until(&mut app, |app| {
            app.status
                .text()
                .contains("leading branch \"missing-main\"")
        });

        assert!(matches!(app.prompt, PromptState::None));
        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Error);
        assert!(
            app.status
                .text()
                .contains("Restore that branch or re-add the project")
        );
    }

    #[test]
    fn project_branch_status_ready_marks_project_not_leading() {
        let mut app = test_app(default_bindings());

        app.worker_tx
            .send(WorkerEvent::ProjectBranchStatusReady {
                project_id: app.projects[0].id.clone(),
                result: Ok(("feature".to_string(), ProjectBranchStatus::NotLeading)),
            })
            .unwrap();

        app.drain_events();

        assert_eq!(app.projects[0].current_branch, "feature");
        assert_eq!(
            app.projects[0].branch_status,
            ProjectBranchStatus::NotLeading
        );
    }

    #[test]
    fn checkout_project_default_success_marks_project_leading() {
        let mut app = test_app(default_bindings());
        let mut project = app.projects[0].clone();
        project.current_branch = "feature".to_string();
        project.branch_status = ProjectBranchStatus::NotLeading;
        app.projects[0] = project.clone();

        app.worker_tx
            .send(WorkerEvent::NonDefaultBranchCheckoutCompleted {
                action: NonDefaultBranchAction::CheckoutProjectDefault { project },
                target_branch: "main".to_string(),
                result: Ok(()),
            })
            .unwrap();

        app.drain_events();

        assert_eq!(app.projects[0].current_branch, "main");
        assert_eq!(app.projects[0].branch_status, ProjectBranchStatus::Leading);
        assert!(app.status.text().contains("Checked out \"main\""));
    }

    #[test]
    fn create_agent_prefills_name_when_randomized_default_enabled() {
        let mut app = test_app(default_bindings());
        app.config.defaults.enable_randomized_pet_name_by_default = true;

        app.create_agent_for_selected_project().unwrap();
        complete_create_agent_branch_inspection(&mut app, "main", "main");

        match &app.prompt {
            PromptState::NameNewAgent {
                input,
                randomize_name,
                randomized_name,
                ..
            } => {
                assert!(*randomize_name);
                assert!(!input.text.is_empty());
                assert_eq!(randomized_name.as_deref(), Some(input.text.as_str()));
            }
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        }
    }

    #[test]
    fn fork_agent_prefills_name_when_randomized_default_enabled() {
        let mut app = test_app(default_bindings());
        app.config.defaults.enable_randomized_pet_name_by_default = true;

        app.fork_selected_session().unwrap();

        match &app.prompt {
            PromptState::NameNewAgent {
                request,
                input,
                randomize_name,
                randomized_name,
                ..
            } => {
                assert!(matches!(request, CreateAgentRequest::ForkSession { .. }));
                assert!(*randomize_name);
                assert!(!input.text.is_empty());
                assert_eq!(randomized_name.as_deref(), Some(input.text.as_str()));
            }
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        }
        assert!(!app.create_agent_in_flight);
    }

    #[test]
    fn palette_command_opens_project_worktree_picker() {
        let mut app = test_app(default_bindings());
        app.selected_left = 0;

        app.execute_command("new-agent-from-worktree".to_string())
            .unwrap();

        match &app.prompt {
            PromptState::PickProjectWorktree(prompt) => {
                assert_eq!(prompt.project.id, app.projects[0].id);
                assert!(prompt.loading);
                assert!(prompt.entries.is_empty());
            }
            other => panic!("expected PickProjectWorktree prompt, got {other:?}"),
        }
    }

    #[test]
    fn disabled_project_worktree_entries_cannot_be_selected_or_confirmed() {
        let mut app = test_app(default_bindings());
        let project = app.projects[0].clone();
        app.prompt = PromptState::PickProjectWorktree(PickProjectWorktreePrompt {
            project,
            entries: vec![ProjectWorktreeEntry {
                path: PathBuf::from("/tmp/has-agent"),
                branch_name: "main".to_string(),
                is_managed_by_dux: true,
                existing_session_id: Some("session-1".to_string()),
                is_external: false,
                is_project_checkout: false,
                is_selectable: false,
            }],
            loading: false,
            selected: None,
            error: None,
        });

        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        assert!(matches!(
            app.prompt,
            PromptState::PickProjectWorktree(PickProjectWorktreePrompt { selected: None, .. })
        ));
    }

    #[test]
    fn selecting_managed_orphan_worktree_opens_name_prompt() {
        let mut app = test_app(default_bindings());
        let project = app.projects[0].clone();
        let worktree_path = app.paths.worktrees_root.join("demo").join("orphan");
        app.prompt = PromptState::PickProjectWorktree(PickProjectWorktreePrompt {
            project,
            entries: vec![ProjectWorktreeEntry {
                path: worktree_path.clone(),
                branch_name: "orphan".to_string(),
                is_managed_by_dux: true,
                existing_session_id: None,
                is_external: false,
                is_project_checkout: false,
                is_selectable: true,
            }],
            loading: false,
            selected: Some(0),
            error: None,
        });

        app.open_selected_project_worktree_agent_prompt().unwrap();

        match &app.prompt {
            PromptState::NameNewAgent { request, input, .. } => match request {
                CreateAgentRequest::ExistingManagedWorktree {
                    project,
                    worktree_path: selected_path,
                    branch_name,
                    ..
                } => {
                    assert_eq!(project.id, app.projects[0].id);
                    assert_eq!(selected_path, &worktree_path);
                    assert_eq!(branch_name, "orphan");
                    assert_eq!(input.text, "orphan");
                }
                other => panic!("expected ExistingManagedWorktree request, got {other:?}"),
            },
            other => panic!("expected NameNewAgent prompt, got {other:?}"),
        }
    }

    #[test]
    fn managed_worktree_default_name_overrides_randomized_default() {
        let mut app = test_app(default_bindings());
        app.config.defaults.enable_randomized_pet_name_by_default = true;
        let project = app.projects[0].clone();
        let worktree_path = app.paths.worktrees_root.join("demo").join("managed-name");
        app.prompt = PromptState::PickProjectWorktree(PickProjectWorktreePrompt {
            project,
            entries: vec![ProjectWorktreeEntry {
                path: worktree_path,
                branch_name: "feature/other-name".to_string(),
                is_managed_by_dux: true,
                existing_session_id: None,
                is_external: false,
                is_project_checkout: false,
                is_selectable: true,
            }],
            loading: false,
            selected: Some(0),
            error: None,
        });

        app.open_selected_project_worktree_agent_prompt().unwrap();

        match &app.prompt {
            PromptState::NameNewAgent {
                input,
                randomize_name,
                randomized_name,
                ..
            } => {
                assert_eq!(input.text, "managed-name");
                assert!(!*randomize_name);
                assert!(randomized_name.is_none());
            }
            other => panic!("expected NameNewAgent prompt, got {other:?}"),
        }
    }

    #[test]
    fn imported_managed_worktree_is_tracked_for_resume_fallback() {
        let mut app = test_app(default_bindings());
        let worktree = app.paths.worktrees_root.join("demo").join("imported");
        std::fs::create_dir_all(&worktree).expect("imported worktree");
        let session = AgentSession {
            id: "imported-session".to_string(),
            project_id: app.projects[0].id.clone(),
            project_path: Some(app.projects[0].path.clone()),
            provider: ProviderKind::from_str("codex"),
            source_branch: "main".to_string(),
            branch_name: "main".to_string(),
            worktree_path: worktree.to_string_lossy().to_string(),
            title: Some("imported".to_string()),
            started_providers: vec!["codex".to_string()],
            desired_running: true,
            auto_reopen_enabled: true,
            status: SessionStatus::Active,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        };
        let args = vec!["-c".to_string(), "sleep 1".to_string()];
        let client = PtyClient::spawn("/bin/sh", &args, &worktree, 24, 80, 1_000)
            .expect("spawn imported agent");

        let request = app.agent_launch_request(
            session.clone(),
            true,
            AgentLaunchKind::Create {
                status_message: "imported".to_string(),
                repo_path: app.projects[0].path.clone(),
                owns_worktree: false,
                startup_result: None,
            },
        );
        app.worker_tx
            .send(WorkerEvent::AgentLaunchReady(Box::new(
                crate::app::AgentLaunchReadyData { request, client },
            )))
            .unwrap();
        app.drain_events();

        assert!(app.resume_fallback_candidates.contains_key(&session.id));
        assert_eq!(
            app.sessions
                .iter()
                .find(|candidate| candidate.id == session.id)
                .and_then(|candidate| candidate.title.as_deref()),
            Some("imported")
        );
    }

    #[test]
    fn selecting_external_worktree_opens_name_prompt_with_fork_request() {
        let mut app = test_app(default_bindings());
        let project = app.projects[0].clone();
        let worktree_path = PathBuf::from("/tmp/external-checkout");
        app.prompt = PromptState::PickProjectWorktree(PickProjectWorktreePrompt {
            project,
            entries: vec![ProjectWorktreeEntry {
                path: worktree_path.clone(),
                branch_name: "feature".to_string(),
                is_managed_by_dux: false,
                existing_session_id: None,
                is_external: true,
                is_project_checkout: false,
                is_selectable: true,
            }],
            loading: false,
            selected: Some(0),
            error: None,
        });

        app.open_selected_project_worktree_agent_prompt().unwrap();

        match &app.prompt {
            PromptState::NameNewAgent { request, input, .. } => match request {
                CreateAgentRequest::ForkExternalWorktree {
                    source_worktree_path,
                    source_branch,
                    ..
                } => {
                    assert_eq!(source_worktree_path, &worktree_path);
                    assert_eq!(source_branch, "feature");
                    assert_eq!(input.text, "external-checkout");
                }
                other => panic!("expected ForkExternalWorktree request, got {other:?}"),
            },
            other => panic!("expected NameNewAgent prompt, got {other:?}"),
        }
    }

    #[test]
    fn external_worktree_default_name_overrides_randomized_default() {
        let mut app = test_app(default_bindings());
        app.config.defaults.enable_randomized_pet_name_by_default = true;
        let project = app.projects[0].clone();
        let worktree_path = PathBuf::from("/tmp/external-checkout");
        app.prompt = PromptState::PickProjectWorktree(PickProjectWorktreePrompt {
            project,
            entries: vec![ProjectWorktreeEntry {
                path: worktree_path,
                branch_name: "feature".to_string(),
                is_managed_by_dux: false,
                existing_session_id: None,
                is_external: true,
                is_project_checkout: false,
                is_selectable: true,
            }],
            loading: false,
            selected: Some(0),
            error: None,
        });

        app.open_selected_project_worktree_agent_prompt().unwrap();

        match &app.prompt {
            PromptState::NameNewAgent {
                input,
                randomize_name,
                randomized_name,
                ..
            } => {
                assert_eq!(input.text, "external-checkout");
                assert!(!*randomize_name);
                assert!(randomized_name.is_none());
            }
            other => panic!("expected NameNewAgent prompt, got {other:?}"),
        }
    }

    #[test]
    fn fresh_agent_request_uses_pull_before_create_config_default() {
        let mut app = test_app(default_bindings());
        app.config.defaults.pull_before_creating_agent_by_default = true;

        app.create_agent_for_selected_project().unwrap();
        complete_create_agent_branch_inspection(&mut app, "main", "main");

        match &app.prompt {
            PromptState::NameNewAgent { request, .. } => match request {
                CreateAgentRequest::NewProject {
                    pull_before_create, ..
                } => assert!(*pull_before_create),
                other => panic!("expected NewProject request, got {other:?}"),
            },
            other => panic!("expected NameNewAgent prompt, got {other:?}"),
        }
    }

    #[test]
    fn fresh_agent_prompt_does_not_expose_pull_before_create_checkbox() {
        let mut app = test_app(default_bindings());
        app.config.defaults.pull_before_creating_agent_by_default = true;

        app.create_agent_for_selected_project().unwrap();
        complete_create_agent_branch_inspection(&mut app, "main", "main");

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();
        match &app.prompt {
            PromptState::NameNewAgent { focus, .. } => {
                assert_eq!(*focus, NameNewAgentFocus::RandomizedNameCheckbox)
            }
            other => panic!("expected NameNewAgent prompt, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();
        match &app.prompt {
            PromptState::NameNewAgent { focus, .. } => assert_eq!(*focus, NameNewAgentFocus::Input),
            other => panic!("expected NameNewAgent prompt, got {other:?}"),
        }
    }

    #[test]
    fn name_prompt_toggle_randomized_name_fills_and_clears_input() {
        let mut app = test_app(default_bindings());
        app.create_agent_for_selected_project().unwrap();
        complete_create_agent_branch_inspection(&mut app, "main", "main");

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
            .unwrap();

        let generated = match &app.prompt {
            PromptState::NameNewAgent {
                input,
                randomize_name,
                randomized_name,
                focus,
                ..
            } => {
                assert!(*randomize_name);
                assert_eq!(*focus, NameNewAgentFocus::RandomizedNameCheckbox);
                assert!(!input.text.is_empty());
                assert_eq!(randomized_name.as_deref(), Some(input.text.as_str()));
                input.text.clone()
            }
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        };

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::NameNewAgent {
                input,
                randomize_name,
                randomized_name,
                ..
            } => {
                assert!(!*randomize_name);
                assert!(input.text.is_empty(), "generated name was {generated}");
                assert!(randomized_name.is_none());
            }
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        }
    }

    #[test]
    fn fork_action_requires_selected_session() {
        let mut app = test_app(default_bindings());
        app.selected_left = 0;

        app.handle_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Error);
        assert!(
            app.status
                .text()
                .contains("Select an agent session first to fork.")
        );
    }

    #[test]
    fn copy_path_copies_selected_session_worktree() {
        let mut app = test_app(default_bindings());
        let _worktree_path = app.sessions[0].worktree_path.clone();
        app.clipboard = Clipboard::from_fn(clipboard_ok);

        app.copy_selected_path().unwrap();
        // Result arrives via WorkerEvent; wait for it.
        drain_until(&mut app, |app| {
            app.status.text() == "Agent's path copied to clipboard."
        });

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Info);
        assert_eq!(app.status.text(), "Agent's path copied to clipboard.");
    }

    #[test]
    fn copy_path_copies_selected_project_path() {
        let mut app = test_app(default_bindings());
        app.selected_left = 0;
        app.clipboard = Clipboard::from_fn(clipboard_ok);

        app.copy_selected_path().unwrap();
        drain_until(&mut app, |app| {
            app.status.text() == "Agent's path copied to clipboard."
        });

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Info);
        assert_eq!(app.status.text(), "Agent's path copied to clipboard.");
    }

    #[test]
    fn copy_path_requires_project_or_session_selection() {
        let mut app = test_app(default_bindings());
        app.left_items_cache.clear();
        app.selected_left = 0;

        app.copy_selected_path().unwrap();

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Error);
        assert!(
            app.status
                .text()
                .contains("No project or agent selected. Select one from the sidebar first.")
        );
    }

    #[test]
    fn copy_path_failure_sets_error_status() {
        let mut app = test_app(default_bindings());
        app.clipboard = Clipboard::from_fn(clipboard_fail);

        app.copy_selected_path().unwrap();
        drain_until(&mut app, |app| {
            app.status.text().contains("Clipboard copy failed")
        });

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Error);
        assert!(app.status.text().contains("Clipboard copy failed"));
        assert!(app.status.text().contains("linux clipboard unavailable"));
    }

    #[test]
    fn scroll_page_up_resolves_in_interactive_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Interactive),
            Some(Action::ScrollPageUp),
        );
    }

    #[test]
    fn scroll_page_down_resolves_in_interactive_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Interactive),
            Some(Action::ScrollPageDown),
        );
    }

    #[test]
    fn scroll_to_top_resolves_home_in_interactive_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Interactive),
            Some(Action::ScrollToTop),
        );
    }

    #[test]
    fn scroll_to_top_resolves_home_in_center_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Home, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Center),
            Some(Action::ScrollToTop),
        );
    }

    #[test]
    fn scroll_to_bottom_resolves_end_in_interactive_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Interactive),
            Some(Action::ScrollToBottom),
        );
    }

    #[test]
    fn scroll_to_bottom_resolves_end_in_center_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::End, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Center),
            Some(Action::ScrollToBottom),
        );
    }

    #[test]
    fn scroll_line_down_resolves_space_in_interactive_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Interactive),
            Some(Action::ScrollLineDown),
        );
    }

    #[test]
    fn ctrl_b_does_not_resolve_scroll_in_center_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
        assert_ne!(
            bindings.lookup(&key, BindingScope::Center),
            Some(Action::ScrollPageUp),
        );
    }

    #[test]
    fn ctrl_f_does_not_resolve_scroll_in_center_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
        assert_ne!(
            bindings.lookup(&key, BindingScope::Center),
            Some(Action::ScrollPageDown),
        );
    }

    #[test]
    fn scroll_line_up_resolves_arrow_up_in_interactive_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Interactive),
            Some(Action::ScrollLineUp),
        );
    }

    #[test]
    fn scroll_line_down_resolves_arrow_down_in_interactive_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Interactive),
            Some(Action::ScrollLineDown),
        );
    }

    #[test]
    fn scroll_line_up_resolves_arrow_up_in_center_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Center),
            Some(Action::ScrollLineUp),
        );
    }

    #[test]
    fn scroll_line_down_resolves_arrow_down_in_center_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Center),
            Some(Action::ScrollLineDown),
        );
    }

    #[test]
    fn scroll_line_down_resolves_space_in_center_scope() {
        let bindings = default_bindings();
        let key = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
        assert_eq!(
            bindings.lookup(&key, BindingScope::Center),
            Some(Action::ScrollLineDown),
        );
    }

    #[test]
    fn slash_search_selects_first_match_and_n_advances() {
        let mut app = test_app(default_bindings());
        app.focus = FocusPane::Files;
        app.right_section = RightSection::Unstaged;
        app.unstaged_files = vec![
            ChangedFile {
                path: "src/lib.rs".into(),
                status: "M".into(),
                additions: 1,
                deletions: 0,
                binary: false,
            },
            ChangedFile {
                path: "src/main.rs".into(),
                status: "M".into(),
                additions: 2,
                deletions: 1,
                binary: false,
            },
        ];
        app.staged_files = vec![ChangedFile {
            path: "tests/main.rs".into(),
            status: "A".into(),
            additions: 3,
            deletions: 0,
            binary: false,
        }];

        app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
            .unwrap();
        for ch in ['m', 'a', 'i', 'n'] {
            app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
                .unwrap();
        }

        assert!(app.files_search_active);
        assert_eq!(app.files_search.text, "main");
        assert_eq!(app.right_section, RightSection::Unstaged);
        assert_eq!(app.files_index, 1);

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();
        app.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.right_section, RightSection::Staged);
        assert_eq!(app.files_index, 0);

        app.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.right_section, RightSection::Unstaged);
        assert_eq!(app.files_index, 1);
    }

    #[test]
    fn esc_clears_active_files_search() {
        let mut app = test_app(default_bindings());
        app.focus = FocusPane::Files;
        app.right_section = RightSection::Unstaged;
        app.unstaged_files = vec![ChangedFile {
            path: "src/main.rs".into(),
            status: "M".into(),
            additions: 2,
            deletions: 1,
            binary: false,
        }];

        app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
            .unwrap();
        for ch in ['m', 'a', 'i', 'n'] {
            app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
                .unwrap();
        }

        assert!(app.files_search_active);
        assert_eq!(app.files_search.text, "main");

        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .unwrap();

        assert!(!app.files_search_active);
        assert!(app.files_search.is_empty());
    }

    #[test]
    fn enter_opens_selected_file_diff_from_files_pane() {
        let mut app = test_app(default_bindings());
        init_git_repo_with_modified_file(
            &app,
            "src/main.rs",
            "fn main() {}\n",
            "fn main() { println!(\"hi\"); }\n",
        );
        app.unstaged_files = vec![ChangedFile {
            path: "src/main.rs".into(),
            status: "M".into(),
            additions: 1,
            deletions: 1,
            binary: false,
        }];
        app.selected_left = 1;
        app.focus = FocusPane::Files;
        app.right_section = RightSection::Unstaged;

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.focus, FocusPane::Center);
        assert!(matches!(app.center_mode, CenterMode::Diff { .. }));
    }

    #[test]
    fn mouse_click_left_row_focuses_and_selects_it() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.focus = FocusPane::Center;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 1));

        assert_eq!(app.focus, FocusPane::Left);
        assert_eq!(app.selected_left, 0);
    }

    #[test]
    fn mouse_click_left_pane_chrome_focuses_left_pane() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.focus = FocusPane::Files;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0));

        assert_eq!(app.focus, FocusPane::Left);
    }

    #[test]
    fn mouse_click_empty_left_list_focuses_left_pane() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.projects.clear();
        app.sessions.clear();
        app.left_items_cache.clear();
        app.selected_left = 0;
        app.focus = FocusPane::Center;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 1));

        assert_eq!(app.focus, FocusPane::Left);
    }

    #[test]
    fn mouse_click_right_row_focuses_and_selects_unstaged_file() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.unstaged_files = vec![
            ChangedFile {
                path: "a.txt".into(),
                status: "M".into(),
                additions: 1,
                deletions: 0,
                binary: false,
            },
            ChangedFile {
                path: "b.txt".into(),
                status: "M".into(),
                additions: 2,
                deletions: 1,
                binary: false,
            },
        ];
        app.focus = FocusPane::Center;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 79, 2));

        assert_eq!(app.focus, FocusPane::Files);
        assert_eq!(app.right_section, RightSection::Unstaged);
        assert_eq!(app.files_index, 1);
        assert!(matches!(app.center_mode, CenterMode::Agent));
    }

    #[test]
    fn mouse_click_right_pane_chrome_focuses_files_pane() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.focus = FocusPane::Left;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 99, 0));

        assert_eq!(app.focus, FocusPane::Files);
    }

    #[test]
    fn mouse_journey_can_switch_focus_across_all_panes() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 99, 0));
        assert_eq!(app.focus, FocusPane::Files);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 5));
        assert_eq!(app.focus, FocusPane::Center);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0));
        assert_eq!(app.focus, FocusPane::Left);
    }

    #[test]
    fn mouse_double_click_project_row_activates_like_enter() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.selected_left = 0;
        app.focus = FocusPane::Left;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 1));
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 1));

        // Double-clicking a project activates it (opens latest session).
        assert_eq!(app.focus, FocusPane::Center);
        assert!(matches!(app.center_mode, CenterMode::Agent));
    }

    #[test]
    fn mouse_double_click_session_row_activates_like_enter() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.selected_left = 1;
        app.focus = FocusPane::Left;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 2));
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 2));

        assert_eq!(app.focus, FocusPane::Center);
        assert!(matches!(app.center_mode, CenterMode::Agent));
        assert_eq!(app.selected_left, 1);
    }

    #[test]
    fn mouse_double_click_left_pane_empty_space_does_not_activate_selected_row() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.selected_left = 1;
        app.focus = FocusPane::Center;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 9));
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 9));

        assert_eq!(app.focus, FocusPane::Left);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        assert_eq!(app.input_target, InputTarget::None);
        assert!(matches!(app.center_mode, CenterMode::Agent));
        assert_eq!(app.selected_left, 1);
    }

    #[test]
    fn mouse_double_click_center_agent_pane_opens_fullscreen() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.selected_left = 1;
        app.center_mode = CenterMode::Agent;
        app.focus = FocusPane::Center;
        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn(
                "sh",
                &["-c".to_string(), "printf ready; sleep 0.2".to_string()],
                std::path::Path::new("."),
                10,
                10,
                100,
            )
            .expect("spawn pty"),
        );

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 5));
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 5));

        assert_eq!(app.focus, FocusPane::Center);
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn mouse_double_click_center_from_left_pane_opens_fullscreen() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.selected_left = 1;
        app.center_mode = CenterMode::Agent;
        app.focus = FocusPane::Left;
        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn(
                "sh",
                &["-c".to_string(), "printf ready; sleep 0.2".to_string()],
                std::path::Path::new("."),
                10,
                10,
                100,
            )
            .expect("spawn pty"),
        );

        // First click focuses the center pane (from Left).
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 5));
        assert_eq!(app.focus, FocusPane::Center);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);

        // Second click completes the double-click and activates fullscreen.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 5));
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn mouse_up_between_clicks_does_not_break_double_click() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.selected_left = 1;
        app.center_mode = CenterMode::Agent;
        app.focus = FocusPane::Center;
        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn(
                "sh",
                &["-c".to_string(), "printf ready; sleep 0.2".to_string()],
                std::path::Path::new("."),
                10,
                10,
                100,
            )
            .expect("spawn pty"),
        );

        // Down → Up → Down mirrors what the event loop sees for a real
        // double-click.  The Up must not interfere with detection.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 5));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 30, 5));
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 5));

        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn mouse_wheel_left_pane_advances_selection_under_cursor() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.focus = FocusPane::Center;
        app.selected_left = 0;

        app.handle_mouse(mouse(MouseEventKind::ScrollDown, 2, 1));

        assert_eq!(app.focus, FocusPane::Left);
        assert_eq!(app.selected_left, 1);
    }

    #[test]
    fn mouse_journey_can_switch_files_sections_by_clicking_rows() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.unstaged_files = vec![ChangedFile {
            path: "a.txt".into(),
            status: "M".into(),
            additions: 1,
            deletions: 0,
            binary: false,
        }];
        app.staged_files = vec![ChangedFile {
            path: "b.txt".into(),
            status: "A".into(),
            additions: 3,
            deletions: 0,
            binary: false,
        }];

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 79, 1));
        assert_eq!(app.right_section, RightSection::Unstaged);
        assert_eq!(app.files_index, 0);
        assert_eq!(app.focus, FocusPane::Files);
        assert!(matches!(app.center_mode, CenterMode::Agent));

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 79, 9));
        assert_eq!(app.right_section, RightSection::Staged);
        assert_eq!(app.files_index, 0);
        assert_eq!(app.focus, FocusPane::Files);
    }

    #[test]
    fn mouse_double_click_unstaged_file_row_opens_diff() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        init_git_repo_with_modified_file(
            &app,
            "src/main.rs",
            "fn main() {}\n",
            "fn main() { println!(\"hi\"); }\n",
        );
        app.unstaged_files = vec![ChangedFile {
            path: "src/main.rs".into(),
            status: "M".into(),
            additions: 1,
            deletions: 1,
            binary: false,
        }];
        app.selected_left = 1;
        app.focus = FocusPane::Files;
        app.right_section = RightSection::Unstaged;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 79, 1));
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 79, 1));

        assert_eq!(app.focus, FocusPane::Center);
        assert!(matches!(app.center_mode, CenterMode::Diff { .. }));
    }

    /// Regression test: double-clicking a file when focus starts on Center
    /// must open the diff even though focus changes (and the mouse layout
    /// shifts due to the hint bar appearing) between the two clicks.
    #[test]
    fn mouse_double_click_unstaged_file_opens_diff_from_center_focus() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        init_git_repo_with_modified_file(
            &app,
            "src/main.rs",
            "fn main() {}\n",
            "fn main() { println!(\"hi\"); }\n",
        );
        app.unstaged_files = vec![ChangedFile {
            path: "src/main.rs".into(),
            status: "M".into(),
            additions: 1,
            deletions: 1,
            binary: false,
        }];
        app.selected_left = 1;

        // Focus starts on the center pane (agent output), NOT on Files.
        app.focus = FocusPane::Center;
        app.right_section = RightSection::Unstaged;

        // Layout before first click: full inner area (no hint bar because
        // the pane is not focused).  unstaged_list rows 1..19 (height 18).
        app.mouse_layout.unstaged_list = Some(Rect::new(78, 1, 21, 18));

        // First click selects the file and moves focus to Files.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 79, 1));
        assert_eq!(app.focus, FocusPane::Files);
        assert!(!matches!(app.center_mode, CenterMode::Diff { .. }));

        // Simulate the render cycle that happens between clicks: the pane is
        // now focused so the hint bar appears, shrinking the list area by 2
        // rows at the bottom (height 18 → 16).
        app.mouse_layout.unstaged_list = Some(Rect::new(78, 1, 21, 16));

        // Second click at the same position must still detect the double-click.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 79, 1));
        assert_eq!(app.focus, FocusPane::Center);
        assert!(matches!(app.center_mode, CenterMode::Diff { .. }));
    }

    #[test]
    fn mouse_wheel_center_diff_scrolls_lines() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.center_mode = CenterMode::Diff {
            lines: Arc::new(vec![
                Line::from("one"),
                Line::from("two"),
                Line::from("three"),
            ]),
            scroll: 0,
            gutter_width: 0,
            worktree_path: String::new(),
            rel_path: String::new(),
        };
        app.last_diff_height = 2;
        app.last_diff_visual_lines = 10;

        app.handle_mouse(mouse(MouseEventKind::ScrollDown, 30, 5));

        match app.center_mode {
            CenterMode::Diff { scroll, .. } => assert_eq!(scroll, 3),
            _ => panic!("expected diff mode"),
        }
    }

    fn open_fake_diff(app: &mut App) {
        app.center_mode = CenterMode::Diff {
            lines: Arc::new(vec![Line::from("diff")]),
            scroll: 0,
            gutter_width: 0,
            worktree_path: String::new(),
            rel_path: String::new(),
        };
    }

    #[test]
    fn left_move_down_closes_open_diff_when_moving_between_agents() {
        let mut app = test_app(default_bindings());
        let now = Utc::now();
        app.sessions.push(AgentSession {
            id: "session-2".to_string(),
            project_id: app.projects[0].id.clone(),
            project_path: Some(app.projects[0].path.clone()),
            provider: ProviderKind::from_str("codex"),
            source_branch: "main".to_string(),
            branch_name: "agent-branch-2".to_string(),
            worktree_path: app.paths.worktrees_root.to_string_lossy().to_string(),
            title: None,
            started_providers: Vec::new(),
            desired_running: false,
            auto_reopen_enabled: true,
            status: SessionStatus::Detached,
            created_at: now,
            updated_at: now,
        });
        app.rebuild_left_items();
        app.selected_left = 1;
        app.focus = FocusPane::Left;
        open_fake_diff(&mut app);

        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.selected_left, 2);
        assert!(matches!(app.center_mode, CenterMode::Agent));
    }

    #[test]
    fn left_move_up_closes_open_diff() {
        let mut app = test_app(default_bindings());
        app.selected_left = 1;
        app.focus = FocusPane::Left;
        open_fake_diff(&mut app);

        app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.selected_left, 0);
        assert!(matches!(app.center_mode, CenterMode::Agent));
    }

    fn append_empty_projects(app: &mut App, count: usize) {
        let path = app.projects[0].path.clone();
        for idx in 0..count {
            app.projects.push(Project {
                id: format!("empty-project-{idx}"),
                name: format!("empty {idx}"),
                path: path.clone(),
                explicit_default_provider: None,
                default_provider: ProviderKind::from_str("codex"),
                leading_branch: Some("main".to_string()),
                auto_reopen_agents: None,
                startup_command: None,
                env: Default::default(),
                current_branch: "main".to_string(),
                branch_status: ProjectBranchStatus::Unknown,
                path_missing: false,
            });
        }
    }

    #[test]
    fn left_navigation_skips_empty_projects_separator() {
        let mut app = test_app(default_bindings());
        append_empty_projects(&mut app, 4);
        app.config.ui.empty_project_separator_min_projects = 5;
        app.rebuild_left_items();
        app.selected_left = 1;
        app.focus = FocusPane::Left;

        assert!(matches!(
            app.left_items().get(2),
            Some(LeftItem::EmptyProjectsSpacer)
        ));
        assert!(matches!(
            app.left_items().get(3),
            Some(LeftItem::EmptyProjectsSeparator)
        ));

        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.selected_left, 4);

        app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))
            .unwrap();
        assert_eq!(app.selected_left, 1);
    }

    #[test]
    fn mouse_click_empty_projects_separator_keeps_selection() {
        let mut app = test_app(default_bindings());
        append_empty_projects(&mut app, 4);
        app.config.ui.empty_project_separator_min_projects = 5;
        app.rebuild_left_items();
        install_mouse_layout(&mut app);
        app.selected_left = 1;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 4));

        assert_eq!(app.selected_left, 1);
    }

    #[test]
    fn mouse_click_different_left_row_closes_open_diff() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.selected_left = 1;
        open_fake_diff(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 1));

        assert_eq!(app.selected_left, 0);
        assert!(matches!(app.center_mode, CenterMode::Agent));
    }

    #[test]
    fn mouse_click_same_left_row_preserves_open_diff() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.selected_left = 1;
        app.focus = FocusPane::Left;
        open_fake_diff(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 2));

        assert_eq!(app.selected_left, 1);
        assert!(matches!(app.center_mode, CenterMode::Diff { .. }));
    }

    #[test]
    fn mouse_drag_left_divider_updates_widths() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 19, 5));
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5));

        assert!(app.left_width_pct > 20);
        assert!(app.left_width_pct + app.right_width_pct <= 80);
    }

    #[test]
    fn mouse_click_commit_text_first_click_only_focuses_commit_input() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.commit_input = TextInput::with_text("hello world".to_string());
        app.focus = FocusPane::Center;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 15));

        assert_eq!(app.focus, FocusPane::Files);
        assert_eq!(app.right_section, RightSection::CommitInput);
        assert_eq!(app.input_target, InputTarget::None);
    }

    #[test]
    fn mouse_click_commit_text_second_click_enters_edit_mode_and_moves_cursor() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.commit_input = TextInput::with_text("abc\ndef".to_string());
        app.commit_input.cursor = 0;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 16));
        assert_eq!(app.input_target, InputTarget::None);
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 16));

        assert_eq!(app.input_target, InputTarget::CommitMessage);
        assert!(app.commit_input.cursor > 0);
    }

    #[test]
    fn mouse_journey_commit_chrome_then_text_enters_editing() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.commit_input = TextInput::with_text("hello".to_string());
        app.focus = FocusPane::Center;

        // Click inside the commit block (below the border row that the divider
        // occupies) so that the first click focuses the commit section.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 15));
        assert_eq!(app.focus, FocusPane::Files);
        assert_eq!(app.right_section, RightSection::CommitInput);
        assert_eq!(app.input_target, InputTarget::None);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 16));
        assert_eq!(app.input_target, InputTarget::CommitMessage);
    }

    #[test]
    fn mouse_wheel_commit_text_scrolls_commit_viewport() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.commit_input =
            TextInput::with_text((0..20).map(|i| format!("line {i}\n")).collect::<String>())
                .with_multiline(4);

        app.handle_mouse(mouse(MouseEventKind::ScrollDown, 80, 15));

        assert!(app.commit_input.scroll_offset() > 0);
        assert_eq!(app.right_section, RightSection::CommitInput);
    }

    #[test]
    fn mouse_journey_divider_drag_persists_widths_on_release() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        let original_left = app.config.ui.left_width_pct;
        let original_right = app.config.ui.right_width_pct;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 19, 5));
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 30, 5));

        assert_ne!(app.left_width_pct, original_left);
        assert_eq!(app.config.ui.left_width_pct, app.left_width_pct);
        assert_eq!(app.config.ui.right_width_pct, app.right_width_pct);
        assert_eq!(app.config.ui.right_width_pct, original_right);
    }

    #[test]
    fn mouse_drag_staged_divider_updates_height() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        // Set up a layout with a gap between unstaged and staged content areas
        // to represent the border row where the divider lives.
        // unstaged inner content: rows 1..7 (y=1, height=6)
        // border gap: row 7
        // staged inner content: rows 8..12 (y=8, height=5)
        app.mouse_layout.unstaged_list = Some(Rect::new(78, 1, 21, 6));
        app.mouse_layout.staged_list = Some(Rect::new(78, 8, 21, 5));
        app.unstaged_files = vec![ChangedFile {
            path: "a.txt".into(),
            status: "M".into(),
            additions: 1,
            deletions: 0,
            binary: false,
        }];
        app.staged_files = vec![ChangedFile {
            path: "b.txt".into(),
            status: "A".into(),
            additions: 1,
            deletions: 0,
            binary: false,
        }];
        let original = app.staged_pane_height_pct;

        // Click on the gap row (7), which is inside the divider zone.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 7));
        assert!(app.mouse_drag.is_some());

        // Drag downward to shrink the staged section.
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 80, 12));
        assert_ne!(app.staged_pane_height_pct, original);

        // Release persists.
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 80, 12));
        assert_eq!(
            app.config.ui.staged_pane_height_pct,
            app.staged_pane_height_pct
        );
    }

    #[test]
    fn mouse_staged_divider_not_detected_without_staged_files() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        // With no staged files, staged_list is None — divider must not appear.
        app.mouse_layout.staged_list = None;
        app.mouse_layout.unstaged_list = Some(Rect::new(78, 1, 21, 18));

        // Click on a row that would be the divider if staged_list existed.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 9));
        assert!(app.mouse_drag.is_none());
    }

    #[test]
    fn mouse_drag_staged_divider_upward_grows_staged_section() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.mouse_layout.unstaged_list = Some(Rect::new(78, 1, 21, 6));
        app.mouse_layout.staged_list = Some(Rect::new(78, 8, 21, 5));
        app.unstaged_files = vec![ChangedFile {
            path: "a.txt".into(),
            status: "M".into(),
            additions: 1,
            deletions: 0,
            binary: false,
        }];
        app.staged_files = vec![ChangedFile {
            path: "b.txt".into(),
            status: "A".into(),
            additions: 1,
            deletions: 0,
            binary: false,
        }];
        let original = app.staged_pane_height_pct;

        // Click divider gap row.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 7));
        assert!(app.mouse_drag.is_some());

        // Drag upward to grow the staged section.
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 80, 3));
        assert!(app.staged_pane_height_pct > original);
    }

    #[test]
    fn mouse_drag_commit_divider_updates_height() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        // staged inner content: rows 9..12 (y=9, height=3)
        // border gap: row 12
        // commit_area: rows 13..19 (y=13, height=6) — includes border
        app.mouse_layout.staged_list = Some(Rect::new(78, 9, 21, 3));
        app.mouse_layout.commit_area = Some(Rect::new(77, 13, 23, 6));
        app.staged_files = vec![ChangedFile {
            path: "b.txt".into(),
            status: "A".into(),
            additions: 1,
            deletions: 0,
            binary: false,
        }];
        let original = app.commit_pane_height_pct;

        // Click on the gap row (12), which is inside the divider zone.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 12));
        assert!(app.mouse_drag.is_some());

        // Drag upward to grow the commit section.
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 80, 10));
        assert!(app.commit_pane_height_pct > original);

        // Release persists.
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 80, 10));
        assert_eq!(
            app.config.ui.commit_pane_height_pct,
            app.commit_pane_height_pct
        );
    }

    #[test]
    fn mouse_commit_divider_not_detected_without_staged_files() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        // No staged files means no staged_list and no commit_area.
        app.mouse_layout.staged_list = None;
        app.mouse_layout.commit_area = None;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 12));
        assert!(app.mouse_drag.is_none());
    }

    #[test]
    fn mouse_drag_commit_divider_downward_shrinks_commit_section() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        app.mouse_layout.staged_list = Some(Rect::new(78, 9, 21, 3));
        app.mouse_layout.commit_area = Some(Rect::new(77, 13, 23, 6));
        app.staged_files = vec![ChangedFile {
            path: "b.txt".into(),
            status: "A".into(),
            additions: 1,
            deletions: 0,
            binary: false,
        }];
        let original = app.commit_pane_height_pct;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 80, 12));
        assert!(app.mouse_drag.is_some());

        // Drag downward to shrink the commit section.
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 80, 16));
        assert!(app.commit_pane_height_pct < original);
    }

    #[test]
    fn command_palette_jk_keys_insert_text_instead_of_navigating() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::Command {
            input: TextInput::new(),
            selected: 0,
        };

        // Press 'j' — should insert into text, not move selection down.
        app.handle_key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE))
            .unwrap();
        match &app.prompt {
            PromptState::Command {
                input, selected, ..
            } => {
                assert_eq!(input.text, "j");
                assert_eq!(*selected, 0, "selection should stay at 0, not move down");
            }
            other => panic!("expected command prompt, got {other:?}"),
        }

        // Press 'k' — should also insert, not move selection up.
        app.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE))
            .unwrap();
        match &app.prompt {
            PromptState::Command { input, .. } => {
                assert_eq!(input.text, "jk");
            }
            other => panic!("expected command prompt, got {other:?}"),
        }
    }

    #[test]
    fn command_palette_hides_current_pr_command_without_known_pr() {
        let app = test_app(default_bindings());
        let commands = app.filtered_palette_commands("open-current-pr");

        assert!(commands.is_empty());
    }

    #[test]
    fn command_palette_shows_current_pr_command_with_known_pr() {
        let mut app = test_app(default_bindings());
        app.pr_statuses.insert(
            "session-1".to_string(),
            PrInfo {
                number: 42,
                state: PrState::Merged,
                title: "Demo PR".to_string(),
                host: "github.com".to_string(),
                owner_repo: "owner/repo".to_string(),
                url: "https://github.com/owner/repo/pull/42".to_string(),
            },
        );

        let commands = app.filtered_palette_commands("open-current-pr");

        assert_eq!(commands.len(), 1);
        assert_eq!(commands[0].palette_name, Some("open-current-pr"));
        assert_eq!(
            app.current_pr_url(),
            Some("https://github.com/owner/repo/pull/42")
        );
    }

    #[test]
    fn footer_hints_show_pr_only_for_center_focus_with_known_pr() {
        let mut app = test_app(default_bindings());
        app.pr_statuses.insert(
            "session-1".to_string(),
            PrInfo {
                number: 42,
                state: PrState::Closed,
                title: "Demo PR".to_string(),
                host: "github.com".to_string(),
                owner_repo: "owner/repo".to_string(),
                url: "https://github.com/owner/repo/pull/42".to_string(),
            },
        );

        let center_hints = app.footer_hints_for(crate::keybindings::HintContext::Center);
        let left_hints = app.footer_hints_for(crate::keybindings::HintContext::LeftSession);

        assert_eq!(center_hints.first().map(|hint| hint.1), Some("PR"));
        assert_eq!(center_hints.first().map(|hint| hint.0.as_str()), Some("p"));
        assert!(!left_hints.iter().any(|(_, desc)| *desc == "PR"));
    }

    #[test]
    fn footer_hints_hide_pr_without_known_pr() {
        let app = test_app(default_bindings());
        let center_hints = app.footer_hints_for(crate::keybindings::HintContext::Center);

        assert!(!center_hints.iter().any(|(_, desc)| *desc == "PR"));
    }

    #[test]
    fn command_palette_gates_new_agent_from_pr_on_github_availability() {
        let mut app = test_app(default_bindings());

        app.github_integration_enabled = true;
        app.gh_status = crate::model::GhStatus::NotInstalled;
        let unavailable_names = app
            .filtered_palette_commands("pr")
            .into_iter()
            .filter_map(|binding| binding.palette_name)
            .collect::<Vec<_>>();
        assert!(!unavailable_names.contains(&"new-agent-from-pr"));

        app.gh_status = crate::model::GhStatus::Available;
        let available_names = app
            .filtered_palette_commands("pr")
            .into_iter()
            .filter_map(|binding| binding.palette_name)
            .collect::<Vec<_>>();
        assert!(available_names.contains(&"new-agent-from-pr"));

        app.github_integration_enabled = false;
        let disabled_names = app
            .filtered_palette_commands("pr")
            .into_iter()
            .filter_map(|binding| binding.palette_name)
            .collect::<Vec<_>>();
        assert!(!disabled_names.contains(&"new-agent-from-pr"));
    }

    #[test]
    fn resolved_pull_request_prefills_name_prompt_with_head_branch() {
        let mut app = test_app(default_bindings());
        let project = app.projects[0].clone();

        app.worker_tx
            .send(WorkerEvent::PullRequestResolved {
                result: Ok(ResolvedPullRequest {
                    project,
                    host: "github.com".to_string(),
                    owner_repo: "octocat/Hello-World".to_string(),
                    number: 42,
                    title: "Fix issue".to_string(),
                    state: "OPEN".to_string(),
                    head_ref_name: "feature/pr-42".to_string(),
                }),
            })
            .expect("send PR resolution");
        app.drain_events();

        match &app.prompt {
            PromptState::NameNewAgent { input, request, .. } => {
                assert_eq!(input.text, "feature/pr-42");
                match request {
                    CreateAgentRequest::PullRequest {
                        number,
                        head_branch,
                        custom_name,
                        ..
                    } => {
                        assert_eq!(*number, 42);
                        assert_eq!(head_branch, "feature/pr-42");
                        assert_eq!(custom_name.as_deref(), Some("feature/pr-42"));
                    }
                    other => panic!("expected PullRequest request, got {other:?}"),
                }
            }
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        }
    }

    #[test]
    fn mouse_click_command_palette_row_selects_then_double_click_executes() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::Command {
            input: TextInput::with_text("help".to_string()),
            selected: 0,
        };
        install_command_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 15, 9));
        assert!(matches!(
            app.prompt,
            PromptState::Command { selected: 0, .. }
        ));

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 15, 9));
        assert!(matches!(app.prompt, PromptState::None));
        assert_eq!(app.help_scroll, Some(0));
    }

    #[test]
    fn mouse_click_command_palette_input_moves_cursor_and_keyboard_inserts_there() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::Command {
            input: TextInput::with_text("help".to_string()),
            selected: 0,
        };
        install_command_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 19, 7));
        app.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::Command { input, .. } => {
                assert_eq!(input.text, "heXlp");
                assert_eq!(input.cursor, 3);
            }
            other => panic!("expected command prompt, got {other:?}"),
        }
    }

    #[test]
    fn mouse_click_project_browser_row_selects_then_double_click_opens_entry() {
        let mut app = test_app(default_bindings());
        let root = PathBuf::from(&app.projects[0].path);
        let child = root.join("child");
        std::fs::create_dir_all(&child).expect("child dir");
        app.prompt = PromptState::BrowseProjects {
            current_dir: root.clone(),
            entries: vec![crate::app::BrowserEntry {
                path: child.clone(),
                label: "child/".to_string(),
                is_git_repo: false,
            }],
            loading: false,
            selected: 0,
            filter: TextInput::new(),
            searching: false,
            editing_path: false,
            path_input: TextInput::new(),
            tab_completions: Vec::new(),
            tab_index: 0,
        };
        install_browser_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 15, 6));
        assert!(matches!(
            app.prompt,
            PromptState::BrowseProjects { selected: 0, .. }
        ));

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 15, 6));
        match &app.prompt {
            PromptState::BrowseProjects {
                current_dir,
                loading,
                selected,
                ..
            } => {
                assert_eq!(current_dir, &child);
                assert!(*loading);
                assert_eq!(*selected, 0);
            }
            _ => panic!("expected browse projects prompt"),
        }
    }

    #[test]
    fn mouse_down_on_change_theme_row_selects_and_previews_immediately() {
        use ratatui::style::Color;

        let mut app = test_app(default_bindings());
        let themes_dir = app.paths.root.join("themes");
        std::fs::create_dir_all(&themes_dir).expect("themes dir");
        std::fs::write(
            themes_dir.join("z_mouse_preview.toml"),
            r##"[meta]
name = "z_mouse_preview"
author = "tests"
variant = "dark"
description = "mouse preview regression test"

[palette]
white = "#ffffff"
cyan = "#00ffff"

[tokens]
"bg.base" = "#123456"
"bg.panel" = "#1b2b3b"
"text.primary" = "white"
"text.muted" = "#c0c0c0"
"text.dim" = "#707070"
"bg.highlight" = "#234567"
"bg.active" = "#345678"
"accent.primary" = "cyan"
"accent.secondary" = "#ff00ff"
"border.focused" = "cyan"
"border.unfocused" = "#555555"
"success" = "#00ff00"
"error" = "#ff0000"
"warning" = "#ffff00"
"info" = "#00ffff"
"dux.app_bg" = "#123456"
"##,
        )
        .expect("write theme");

        app.open_change_theme_prompt().expect("open change theme");
        let item_count = match &app.prompt {
            PromptState::ChangeTheme(prompt) => prompt.options.len(),
            other => panic!("expected change theme prompt, got {other:?}"),
        };
        install_change_theme_overlay(&mut app, item_count);

        let target_index = match &app.prompt {
            PromptState::ChangeTheme(prompt) => prompt
                .options
                .iter()
                .position(|option| option.id == "z_mouse_preview")
                .expect("custom theme listed"),
            other => panic!("expected change theme prompt, got {other:?}"),
        };

        let row = 9 + target_index as u16;
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 15, row));

        match &app.prompt {
            PromptState::ChangeTheme(prompt) => {
                assert_eq!(prompt.selected, target_index);
            }
            other => panic!("expected change theme prompt, got {other:?}"),
        }
        assert_eq!(app.pressed_button, None);
        assert_eq!(app.theme.app_bg, Color::Rgb(0x12, 0x34, 0x56));
    }

    #[test]
    fn mouse_click_resource_monitor_parent_row_toggles_expansion() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ResourceMonitor {
            rows: vec![
                ResourceStats {
                    label: "dux".into(),
                    pid: Some(1),
                    cpu_percent: 0.0,
                    rss_bytes: 0,
                    process_count: 1,
                    children: Vec::new(),
                },
                ResourceStats {
                    label: "Agent".into(),
                    pid: Some(100),
                    cpu_percent: 5.0,
                    rss_bytes: 1024,
                    process_count: 3,
                    children: vec![
                        ProcessInfo {
                            name: "node".into(),
                            pid: 101,
                            cpu_percent: 3.0,
                            rss_bytes: 512,
                        },
                        ProcessInfo {
                            name: "claude".into(),
                            pid: 102,
                            cpu_percent: 2.0,
                            rss_bytes: 256,
                        },
                    ],
                },
                ResourceStats {
                    label: "TOTAL".into(),
                    pid: None,
                    cpu_percent: 5.0,
                    rss_bytes: 1024,
                    process_count: 4,
                    children: Vec::new(),
                },
            ],
            scroll_offset: 0,
            selected_row: 0,
            expanded: std::collections::HashSet::new(),
            last_refresh: std::time::Instant::now(),
            first_sample: false,
        };
        install_resource_monitor_overlay(&mut app, 3);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 15, 7));

        match &app.prompt {
            PromptState::ResourceMonitor {
                selected_row,
                expanded,
                ..
            } => {
                assert_eq!(*selected_row, 1);
                assert!(expanded.contains(&100), "agent row should expand on click");
            }
            other => panic!("expected resource monitor prompt, got {other:?}"),
        }
    }

    #[test]
    fn mouse_click_browser_search_input_moves_cursor_and_edits_filter() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::BrowseProjects {
            current_dir: PathBuf::from(&app.projects[0].path),
            entries: Vec::new(),
            loading: false,
            selected: 0,
            filter: TextInput::with_text("child".to_string()),
            searching: false,
            editing_path: false,
            path_input: TextInput::new(),
            tab_completions: Vec::new(),
            tab_index: 0,
        };
        install_browser_overlay(&mut app, 0);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 19, 4));
        app.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::BrowseProjects {
                filter, searching, ..
            } => {
                assert_eq!(filter.text, "chXild");
                assert_eq!(filter.cursor, 3);
                assert!(*searching);
            }
            other => panic!("expected browse projects prompt, got {other:?}"),
        }
    }

    #[test]
    fn mouse_click_browser_path_input_moves_cursor_and_edits_path() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::BrowseProjects {
            current_dir: PathBuf::from(&app.projects[0].path),
            entries: Vec::new(),
            loading: false,
            selected: 0,
            filter: TextInput::new(),
            searching: false,
            editing_path: true,
            path_input: TextInput::with_text("abcd".to_string()),
            tab_completions: Vec::new(),
            tab_index: 0,
        };
        install_browser_overlay(&mut app, 0);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 20, 4));
        app.handle_key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::BrowseProjects { path_input, .. } => {
                assert_eq!(path_input.text, "aXbcd");
                assert_eq!(path_input.cursor, 2);
            }
            other => panic!("expected browse projects prompt, got {other:?}"),
        }
    }

    #[test]
    fn project_browser_typed_enter_failure_opens_modal_and_restores_path() {
        let mut app = test_app(default_bindings());
        let root = PathBuf::from(&app.projects[0].path);
        let outside = tempdir().expect("outside tempdir");
        let typed = outside.path().join("not-a-repo");
        std::fs::create_dir_all(&typed).expect("typed dir");
        app.prompt = PromptState::BrowseProjects {
            current_dir: root,
            entries: Vec::new(),
            loading: false,
            selected: 0,
            filter: TextInput::new(),
            searching: false,
            editing_path: true,
            path_input: TextInput::with_text(typed.to_string_lossy().to_string()),
            tab_completions: Vec::new(),
            tab_index: 0,
        };

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::AddProjectFailed { message, .. } => {
                assert!(message.contains("not a git repository"));
            }
            other => panic!("expected add-project failure modal, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::BrowseProjects {
                editing_path,
                path_input,
                ..
            } => {
                assert!(*editing_path);
                assert_eq!(path_input.text, typed.to_string_lossy());
            }
            other => panic!("expected browser prompt restored, got {other:?}"),
        }
    }

    #[test]
    fn project_browser_typed_enter_duplicate_failure_restores_path() {
        let mut app = test_app(default_bindings());
        let root = PathBuf::from(&app.projects[0].path);
        app.prompt = PromptState::BrowseProjects {
            current_dir: root.clone(),
            entries: Vec::new(),
            loading: false,
            selected: 0,
            filter: TextInput::new(),
            searching: false,
            editing_path: true,
            path_input: TextInput::with_text(root.to_string_lossy().to_string()),
            tab_completions: Vec::new(),
            tab_index: 0,
        };

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::AddProjectFailed { message, .. } => {
                assert!(message.contains("already registered"));
            }
            other => panic!("expected add-project failure modal, got {other:?}"),
        }
    }

    #[test]
    fn added_project_is_selected_after_save_completes() {
        let mut app = test_app(default_bindings());
        let project_dir = app.paths.root.join("second-project");
        std::fs::create_dir_all(&project_dir).expect("project dir");
        init_test_repo(&project_dir);

        app.add_project(project_dir.to_string_lossy().to_string(), String::new())
            .expect("add project");
        drain_until(&mut app, |app| {
            app.status
                .text()
                .contains("Added project \"second-project\" to workspace")
        });

        let selected = app.selected_project().expect("selected project");
        assert_eq!(selected.name, "second-project");
        assert_eq!(
            PathBuf::from(&selected.path).canonicalize().unwrap(),
            project_dir.canonicalize().unwrap()
        );
    }

    #[test]
    fn project_browser_typed_arrows_select_and_tab_applies_completion() {
        let mut app = test_app(default_bindings());
        let root = PathBuf::from(&app.projects[0].path);
        let alpha = root.join("alpha");
        let alpine = root.join("alpine");
        std::fs::create_dir_all(&alpha).expect("alpha dir");
        std::fs::create_dir_all(&alpine).expect("alpine dir");
        std::fs::create_dir_all(alpha.join("child")).expect("child dir");
        let typed = root.join("al").to_string_lossy().to_string();
        app.prompt = PromptState::BrowseProjects {
            current_dir: root.clone(),
            entries: Vec::new(),
            loading: false,
            selected: 0,
            filter: TextInput::new(),
            searching: false,
            editing_path: true,
            path_input: TextInput::with_text(typed.clone()),
            tab_completions: Vec::new(),
            tab_index: 0,
        };

        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
            .unwrap();
        match &app.prompt {
            PromptState::BrowseProjects {
                path_input,
                tab_completions,
                tab_index,
                ..
            } => {
                assert_eq!(tab_completions.len(), 2);
                assert_eq!(*tab_index, 0);
                assert_eq!(path_input.text, typed);
            }
            other => panic!("expected browser prompt, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
            .unwrap();
        match &app.prompt {
            PromptState::BrowseProjects {
                path_input,
                tab_index,
                ..
            } => {
                assert_eq!(*tab_index, 1);
                assert_eq!(path_input.text, typed);
            }
            other => panic!("expected browser prompt, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();
        match &app.prompt {
            PromptState::BrowseProjects {
                path_input,
                tab_completions,
                tab_index,
                ..
            } => {
                assert!(
                    path_input.text.ends_with("alpha/") || path_input.text.ends_with("alpine/")
                );
                assert_eq!(*tab_index, 0);
                if path_input.text.ends_with("alpha/") {
                    assert_eq!(tab_completions.len(), 1);
                    assert!(tab_completions[0].ends_with("child/"));
                }
            }
            other => panic!("expected browser prompt, got {other:?}"),
        }
    }

    #[test]
    fn project_browser_typed_up_down_select_completions_without_applying() {
        let mut app = test_app(default_bindings());
        let root = PathBuf::from(&app.projects[0].path);
        app.prompt = PromptState::BrowseProjects {
            current_dir: root,
            entries: Vec::new(),
            loading: false,
            selected: 0,
            filter: TextInput::new(),
            searching: false,
            editing_path: true,
            path_input: TextInput::with_text("/tmp/demo".to_string()),
            tab_completions: vec!["/tmp/demo/".to_string(), "/tmp/demo-two/".to_string()],
            tab_index: 1,
        };

        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
            .unwrap();
        match &app.prompt {
            PromptState::BrowseProjects {
                path_input,
                tab_index,
                ..
            } => {
                assert_eq!(*tab_index, 0);
                assert_eq!(path_input.text, "/tmp/demo");
            }
            other => panic!("expected browser prompt, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::BrowseProjects {
                path_input,
                tab_completions,
                tab_index,
                ..
            } => {
                assert_eq!(tab_completions.len(), 2);
                assert_eq!(*tab_index, 1);
                assert_eq!(path_input.text, "/tmp/demo");
            }
            other => panic!("expected browser prompt, got {other:?}"),
        }
    }

    #[test]
    fn project_browser_exit_path_editor_binding_returns_to_browse_mode() {
        let mut app = test_app(default_bindings());
        let root = PathBuf::from(&app.projects[0].path);
        app.prompt = PromptState::BrowseProjects {
            current_dir: root,
            entries: Vec::new(),
            loading: false,
            selected: 0,
            filter: TextInput::new(),
            searching: false,
            editing_path: true,
            path_input: TextInput::with_text("/tmp/demo".to_string()),
            tab_completions: vec!["/tmp/demo/".to_string()],
            tab_index: 0,
        };

        app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL))
            .unwrap();

        match &app.prompt {
            PromptState::BrowseProjects {
                editing_path,
                path_input,
                tab_completions,
                ..
            } => {
                assert!(!*editing_path);
                assert!(path_input.is_empty());
                assert!(tab_completions.is_empty());
            }
            other => panic!("expected browser prompt, got {other:?}"),
        }
    }

    #[test]
    fn mouse_click_pick_editor_row_selects_then_double_click_opens_editor() {
        let mut app = test_app(default_bindings());
        std::fs::create_dir_all(&app.sessions[0].worktree_path).expect("worktree");
        app.prompt = PromptState::PickEditor {
            session_label: "agent-branch".to_string(),
            worktree_path: app.sessions[0].worktree_path.clone(),
            editors: vec![DetectedEditor {
                kind: EditorKind::VsCode,
                label: "VS Code",
                config_key: "vscode",
                command: "true".to_string(),
            }],
            selected: 0,
        };
        install_pick_editor_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 20, 8));
        assert!(matches!(
            app.prompt,
            PromptState::PickEditor { selected: 0, .. }
        ));

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 20, 8));
        assert!(matches!(app.prompt, PromptState::None));
        assert!(app.status.text().contains("Opened agent"));
    }

    #[test]
    fn mouse_click_kill_running_row_toggles_selection() {
        let mut app = test_app(default_bindings());
        let runtime_id = RuntimeTargetId::Agent("session-1".to_string());
        app.prompt = PromptState::KillRunning(KillRunningPrompt {
            runtimes: vec![sample_runtime(
                runtime_id.clone(),
                KillableRuntimeKind::Agent,
                "agent-branch",
                "demo / codex / agent-branch",
            )],
            filter: TextInput::new(),
            searching: false,
            hovered_visible_index: 0,
            selected_ids: std::collections::HashSet::new(),
            focus: KillRunningFocus::List,
        });
        install_kill_running_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 12, 6));

        match &app.prompt {
            PromptState::KillRunning(prompt) => {
                assert!(prompt.selected_ids.contains(&runtime_id));
            }
            other => panic!("expected kill-running prompt, got {other:?}"),
        }
    }

    #[test]
    fn mouse_click_kill_selected_button_opens_confirmation() {
        let mut app = test_app(default_bindings());
        let runtime_id = RuntimeTargetId::Agent("session-1".to_string());
        app.prompt = PromptState::KillRunning(KillRunningPrompt {
            runtimes: vec![sample_runtime(
                runtime_id.clone(),
                KillableRuntimeKind::Agent,
                "agent-branch",
                "demo / codex / agent-branch",
            )],
            filter: TextInput::new(),
            searching: false,
            hovered_visible_index: 0,
            selected_ids: std::iter::once(runtime_id).collect(),
            focus: KillRunningFocus::Footer(KillRunningFooterAction::Selected),
        });
        install_kill_running_overlay(&mut app, 1);

        // Buttons activate on mouse-up (with the cursor still inside the
        // originally-pressed button), so a click is Down+Up at the same
        // coordinates.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 47, 16));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 47, 16));

        match &app.prompt {
            PromptState::ConfirmKillRunning(confirm_prompt) => {
                assert_eq!(confirm_prompt.action, KillRunningAction::Selected);
                assert_eq!(confirm_prompt.target_ids.len(), 1);
            }
            other => panic!("expected confirm kill prompt, got {other:?}"),
        }
    }

    // Mouse-down on a modal button arms the press but does not fire the
    // action — the activation lives on mouse-up so that dragging off the
    // button cancels the click (universal GUI convention).
    #[test]
    fn prompt_mouse_down_on_button_arms_pressed_state_without_firing() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(confirm_kill_running_prompt());
        install_confirm_kill_running_overlay(&mut app);

        // Click inside the Kill button (cols 52..68, row 10..13).
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 55, 11));

        assert!(matches!(app.prompt, PromptState::ConfirmKillRunning(_)));
        assert_eq!(
            app.pressed_button,
            Some(PressedButton {
                target: ButtonPressedTarget::ConfirmKillConfirm,
                inside: true,
            })
        );
    }

    #[test]
    fn prompt_mouse_up_inside_pressed_button_fires_action() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(confirm_kill_running_prompt());
        install_confirm_kill_running_overlay(&mut app);

        // Click Cancel — deterministically returns to the prior
        // KillRunning prompt regardless of running-process state.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 35, 11));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 35, 11));

        assert!(matches!(app.prompt, PromptState::KillRunning(_)));
        assert_eq!(app.pressed_button, None);
    }

    #[test]
    fn prompt_mouse_drag_outside_clears_inside_flag() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(confirm_kill_running_prompt());
        install_confirm_kill_running_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 55, 11));
        // Drag far outside the modal — coordinate (0, 0) is well outside
        // either button rect.
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 0, 0));

        assert_eq!(
            app.pressed_button,
            Some(PressedButton {
                target: ButtonPressedTarget::ConfirmKillConfirm,
                inside: false,
            })
        );
    }

    #[test]
    fn prompt_mouse_up_outside_pressed_button_does_not_fire() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(confirm_kill_running_prompt());
        install_confirm_kill_running_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 55, 11));
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 0, 0));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 0, 0));

        // Modal still open, action did not run, press cleared.
        assert!(matches!(app.prompt, PromptState::ConfirmKillRunning(_)));
        assert_eq!(app.pressed_button, None);
    }

    #[test]
    fn prompt_mouse_drag_back_inside_re_arms_action() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(confirm_kill_running_prompt());
        install_confirm_kill_running_overlay(&mut app);

        // Press Cancel, drag out, drag back in, release — the press
        // re-arms when the cursor returns to the original button so the
        // action fires on Up.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 35, 11));
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 0, 0));
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 35, 11));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 35, 11));

        assert!(matches!(app.prompt, PromptState::KillRunning(_)));
        assert_eq!(app.pressed_button, None);
    }

    #[test]
    fn prompt_mouse_up_on_sibling_button_does_not_fire() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(confirm_kill_running_prompt());
        install_confirm_kill_running_overlay(&mut app);

        // Press Kill, release on Cancel — neither fires.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 55, 11));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 35, 11));

        assert!(matches!(app.prompt, PromptState::ConfirmKillRunning(_)));
        assert_eq!(app.pressed_button, None);
    }

    #[test]
    fn prompt_keystroke_clears_pressed_state() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(confirm_kill_running_prompt());
        install_confirm_kill_running_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 55, 11));
        assert!(app.pressed_button.is_some());

        // Any key event during a held press cancels the press so the
        // visual highlight does not outlive its trigger.
        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.pressed_button, None);
    }

    #[test]
    fn prompt_modal_close_clears_pressed_state_via_watchdog() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmKillRunning(confirm_kill_running_prompt());
        install_confirm_kill_running_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 55, 11));
        assert!(app.pressed_button.is_some());

        // Modal closes via some other path (e.g. a non-prompt action). The
        // next mouse event should drop the stale press without firing
        // anything.
        app.prompt = PromptState::None;
        app.overlay_layout.active = OverlayMouseLayout::None;
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 55, 11));

        assert_eq!(app.pressed_button, None);
    }

    #[test]
    fn prompt_mouse_down_on_checkbox_still_fires_on_down() {
        // Regression guard: non-button targets (text inputs, list rows,
        // checkboxes) keep their original on-Down behavior so users get
        // immediate feedback for cursor moves and toggles.
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmDeleteAgent {
            session_id: app.sessions[0].id.clone(),
            branch_name: app.sessions[0].branch_name.clone(),
            focus: DeleteAgentFocus::Cancel,
            delete_worktree: false,
            worktree_shared: false,
        };
        install_confirm_delete_overlay(&mut app);

        // Click the checkbox at (30, 7) — install_confirm_delete_overlay
        // places the checkbox at (24, 7, 44, 1).
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 7));

        match &app.prompt {
            PromptState::ConfirmDeleteAgent {
                delete_worktree, ..
            } => {
                assert!(*delete_worktree, "checkbox toggles on mouse-down");
            }
            other => panic!("expected delete prompt, got {other:?}"),
        }
        assert_eq!(app.pressed_button, None);
    }

    #[test]
    fn mouse_click_quit_dialog_buttons_cancel_or_exit() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmQuit {
            agent_count: 1,
            terminal_count: 0,
            confirm_selected: false,
        };
        install_confirm_quit_overlay(&mut app);
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 35, 10));
        let should_quit = app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 35, 10));
        assert!(!should_quit);
        assert!(matches!(app.prompt, PromptState::None));

        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmQuit {
            agent_count: 1,
            terminal_count: 0,
            confirm_selected: true,
        };
        install_confirm_quit_overlay(&mut app);
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 53, 10));
        let should_quit = app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 53, 10));
        assert!(should_quit);
        assert!(matches!(app.prompt, PromptState::None));
    }

    #[test]
    fn shift_tab_toggles_confirm_quit_selection() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmQuit {
            agent_count: 1,
            terminal_count: 0,
            confirm_selected: false,
        };

        app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT))
            .unwrap();

        match app.prompt {
            PromptState::ConfirmQuit {
                confirm_selected, ..
            } => assert!(confirm_selected, "Shift-Tab should move selection to Quit"),
            other => panic!("expected quit confirmation, got {other:?}"),
        }
    }

    #[test]
    fn quit_prompt_counts_running_companion_terminals_and_agents() {
        let mut app = test_app(default_bindings());
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let (command, args) = ("/bin/sh", vec!["-c".to_string(), "sleep 5".to_string()]);
        let provider =
            PtyClient::spawn(command, &args, worktree, 24, 80, 1_000).expect("spawn test agent");
        let session_id = app.sessions[0].id.clone();
        app.providers.insert(session_id.clone(), provider);
        // Insert a companion terminal to simulate a running terminal.
        let term_client =
            PtyClient::spawn(command, &args, worktree, 24, 80, 1_000).expect("spawn test terminal");
        app.companion_terminals.insert(
            "term-test".to_string(),
            crate::app::CompanionTerminal {
                session_id,
                label: "test".to_string(),
                foreground_cmd: None,
                client: term_client,
            },
        );

        let should_quit = app
            .handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))
            .expect("handle quit");

        assert!(!should_quit);
        match app.prompt {
            PromptState::ConfirmQuit {
                agent_count,
                terminal_count,
                ..
            } => {
                assert_eq!(agent_count, 1);
                assert_eq!(terminal_count, 1);
            }
            _ => panic!("expected quit confirmation"),
        }
    }

    #[test]
    fn change_agent_provider_swaps_in_place_when_stopped() {
        let mut app = test_app(default_bindings());
        let original_session_id = app.sessions[0].id.clone();
        let original_worktree = app.sessions[0].worktree_path.clone();
        app.sessions[0].provider = ProviderKind::from_str("codex");
        app.sessions[0].status = SessionStatus::Detached;
        app.session_store
            .upsert_session(&app.sessions[0])
            .expect("persist session");

        app.rebuild_left_items();
        app.selected_left = app
            .left_items()
            .iter()
            .position(|item| matches!(item, LeftItem::Session(index) if *index == 0))
            .expect("select the seeded session");

        app.open_change_agent_provider_prompt()
            .expect("open provider picker");
        match &mut app.prompt {
            PromptState::ChangeAgentProvider(prompt) => {
                prompt.selected = prompt
                    .options
                    .iter()
                    .position(|option| option.provider.as_str() == "gemini")
                    .expect("gemini option");
            }
            other => panic!("expected change-agent-provider prompt, got {other:?}"),
        }

        app.apply_change_agent_provider()
            .expect("apply provider swap");

        // Exactly one session still exists; its provider was mutated in place.
        assert_eq!(app.sessions.len(), 1);
        assert_eq!(app.sessions[0].id, original_session_id);
        assert_eq!(app.sessions[0].provider.as_str(), "gemini");
        assert_eq!(app.sessions[0].worktree_path, original_worktree);
        assert!(matches!(app.prompt, PromptState::None));

        let persisted = app.session_store.load_sessions().expect("load sessions");
        assert_eq!(persisted.len(), 1);
        assert_eq!(persisted[0].provider.as_str(), "gemini");

        assert!(app.status.text().contains("will use gemini next launch"));
    }

    #[test]
    fn change_agent_provider_swaps_but_warns_when_agent_is_running() {
        let mut app = test_app(default_bindings());
        app.sessions[0].provider = ProviderKind::from_str("codex");
        app.sessions[0].status = SessionStatus::Active;
        let session_id = app.sessions[0].id.clone();

        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let provider = PtyClient::spawn(
            "/bin/sh",
            &["-c".to_string(), "sleep 5".to_string()],
            worktree,
            24,
            80,
            1_000,
        )
        .expect("spawn test agent");
        app.providers.insert(session_id.clone(), provider);

        app.rebuild_left_items();
        app.selected_left = app
            .left_items()
            .iter()
            .position(|item| matches!(item, LeftItem::Session(index) if *index == 0))
            .expect("select the running session");

        app.open_change_agent_provider_prompt()
            .expect("open provider picker");
        match &mut app.prompt {
            PromptState::ChangeAgentProvider(prompt) => {
                prompt.selected = prompt
                    .options
                    .iter()
                    .position(|option| option.provider.as_str() == "gemini")
                    .expect("gemini option");
            }
            other => panic!("expected change-agent-provider prompt, got {other:?}"),
        }

        app.apply_change_agent_provider()
            .expect("apply provider swap");

        // Provider is swapped now; the warning just tells the user the running
        // PTY hasn't caught up yet.
        assert_eq!(app.sessions[0].provider.as_str(), "gemini");
        let persisted = app.session_store.load_sessions().expect("load sessions");
        assert_eq!(persisted[0].provider.as_str(), "gemini");
        assert!(
            app.providers.contains_key(&session_id),
            "the old PTY keeps running until the user exits it"
        );
        assert_eq!(
            app.status.tone(),
            crate::statusline::StatusTone::Warning,
            "status should be a warning, not an error"
        );
        let message = app.status.message().to_string();
        assert!(
            message.contains("still running") && message.contains("gemini"),
            "status should explain the agent still runs the old provider, got: {message}"
        );
        assert!(matches!(app.prompt, PromptState::None));

        // Clean up so the PTY doesn't outlive the test.
        app.providers.remove(&session_id);
    }

    #[test]
    fn change_agent_provider_preserves_resume_for_previously_started_provider() {
        let mut app = test_app(default_bindings());
        app.sessions[0].provider = ProviderKind::from_str("claude");
        app.sessions[0].status = SessionStatus::Detached;
        app.sessions[0].started_providers = vec!["codex".to_string(), "claude".to_string()];
        app.session_store
            .upsert_session(&app.sessions[0])
            .expect("persist session with history");

        app.rebuild_left_items();
        app.selected_left = app
            .left_items()
            .iter()
            .position(|item| matches!(item, LeftItem::Session(index) if *index == 0))
            .expect("select the seeded session");

        app.open_change_agent_provider_prompt()
            .expect("open provider picker");
        match &mut app.prompt {
            PromptState::ChangeAgentProvider(prompt) => {
                let codex_option = prompt
                    .options
                    .iter()
                    .find(|option| option.provider.as_str() == "codex")
                    .expect("codex option present");
                assert!(
                    codex_option.resume_available,
                    "codex was started earlier — picker should advertise resume"
                );
                prompt.selected = prompt
                    .options
                    .iter()
                    .position(|option| option.provider.as_str() == "codex")
                    .expect("codex option");
            }
            other => panic!("expected change-agent-provider prompt, got {other:?}"),
        }

        app.apply_change_agent_provider()
            .expect("swap back to codex");

        assert_eq!(app.sessions[0].provider.as_str(), "codex");
        // should_resume_session uses started_providers + config's resume_args.
        let session = app.sessions[0].clone();
        assert!(
            app.should_resume_session(&session),
            "codex was launched on this worktree earlier, so resume must be active"
        );
        assert!(
            app.status.text().contains("resume its prior session"),
            "status should note resume is happening, got: {}",
            app.status.text()
        );
    }

    #[test]
    fn change_agent_provider_marks_providers_without_resume_support() {
        let mut app = test_app(default_bindings());
        app.sessions[0].provider = ProviderKind::from_str("codex");
        app.sessions[0].status = SessionStatus::Detached;
        // Pretend copilot was launched earlier — it still shouldn't advertise
        // resume because copilot's config has `resume_args: None`.
        app.sessions[0].started_providers = vec!["copilot".to_string()];

        app.rebuild_left_items();
        app.selected_left = app
            .left_items()
            .iter()
            .position(|item| matches!(item, LeftItem::Session(index) if *index == 0))
            .expect("select the seeded session");

        app.open_change_agent_provider_prompt()
            .expect("open picker");
        match &app.prompt {
            PromptState::ChangeAgentProvider(prompt) => {
                let copilot = prompt
                    .options
                    .iter()
                    .find(|option| option.provider.as_str() == "copilot")
                    .expect("copilot option present");
                assert!(
                    !copilot.supports_resume,
                    "copilot is configured without resume_args, so supports_resume must be false"
                );
                assert!(
                    !copilot.resume_available,
                    "even with prior launches, copilot cannot resume"
                );

                let codex = prompt
                    .options
                    .iter()
                    .find(|option| option.provider.as_str() == "codex")
                    .expect("codex option present");
                assert!(
                    codex.supports_resume,
                    "codex supports resume via resume_args"
                );
            }
            other => panic!("expected change-agent-provider prompt, got {other:?}"),
        }
    }

    #[test]
    fn launch_companion_terminal_sets_runtime_state_and_overlay() {
        let mut app = test_app(default_bindings());

        app.show_companion_terminal()
            .expect("launch companion terminal");

        assert_eq!(
            app.selected_companion_terminal_status(),
            CompanionTerminalStatus::Running
        );
        assert_eq!(app.companion_terminals.len(), 1);
        assert!(app.active_terminal_id.is_some());
        assert_eq!(app.session_surface, SessionSurface::Terminal);
        assert_eq!(app.input_target, InputTarget::Terminal);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Terminal);
    }

    #[test]
    fn multiple_terminals_per_session() {
        let mut app = test_app(default_bindings());

        app.show_companion_terminal().expect("first terminal");
        let first_id = app.active_terminal_id.clone().unwrap();

        // Close overlay, launch another.
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.input_target = InputTarget::None;
        app.session_surface = SessionSurface::Agent;

        app.show_companion_terminal().expect("second terminal");
        let second_id = app.active_terminal_id.clone().unwrap();

        assert_ne!(first_id, second_id);
        assert_eq!(app.companion_terminals.len(), 2);
        assert_eq!(app.terminal_items().len(), 2);
    }

    #[test]
    fn exit_interactive_from_terminal_overlay_resets_state() {
        let mut app = test_app(default_bindings());

        app.show_companion_terminal()
            .expect("launch companion terminal");

        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Terminal);
        assert_eq!(app.input_target, InputTarget::Terminal);

        // Simulate ExitInteractive via the raw input path: feed Ctrl-G
        // (0x07) into the raw input buffer and process sequences.
        app.raw_input_buf = vec![0x07];
        let (sequences, _) = crate::raw_input::split_sequences(&app.raw_input_buf);
        assert_eq!(sequences.len(), 1);
        let matched = app.interactive_patterns.match_sequence(sequences[0]);
        assert_eq!(matched, Some((Action::ExitInteractive, false)));

        // Apply the same state change that poll_and_forward_raw_input does.
        let return_to_list =
            matches!(app.input_target, InputTarget::Terminal) && app.terminal_return_to_list;
        app.input_target = InputTarget::None;
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.session_surface = SessionSurface::Agent;
        app.raw_input_buf.clear();
        if return_to_list {
            app.left_section = LeftSection::Terminals;
            app.focus = FocusPane::Left;
        }

        // Launched via `t` → returns to terminals list on the left pane.
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        assert_eq!(app.session_surface, SessionSurface::Agent);
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.left_section, LeftSection::Terminals);
        assert_eq!(app.focus, FocusPane::Left);
    }

    #[test]
    fn exit_interactive_from_agent_overlay_returns_to_projects() {
        let mut app = test_app(default_bindings());
        app.focus = FocusPane::Center;
        app.left_section = LeftSection::Terminals;
        app.input_target = InputTarget::Agent;
        app.session_surface = SessionSurface::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;

        app.process_raw_input_bytes(&[0x07]).unwrap();

        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        assert_eq!(app.session_surface, SessionSurface::Agent);
        assert_eq!(app.left_section, LeftSection::Projects);
        assert_eq!(app.focus, FocusPane::Left);
    }

    #[test]
    fn custom_exit_interactive_key_reopens_minimized_agent_from_projects() {
        let bindings = bindings_with_overrides(&[(Action::ExitInteractive, &["home"])]);
        let mut app = test_app(bindings);
        let session_id = app.sessions[0].id.clone();
        app.providers.insert(
            session_id,
            PtyClient::spawn(
                "sh",
                &["-c".to_string(), "printf ready; sleep 0.2".to_string()],
                std::path::Path::new("."),
                10,
                10,
                100,
            )
            .expect("spawn pty"),
        );

        app.focus = FocusPane::Center;
        app.input_target = InputTarget::Agent;
        app.session_surface = SessionSurface::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;

        app.process_raw_input_bytes(b"\x1b[H").unwrap();

        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        assert_eq!(app.left_section, LeftSection::Projects);
        assert_eq!(app.focus, FocusPane::Left);

        app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
        assert_eq!(app.focus, FocusPane::Center);
    }

    #[test]
    fn ctrl_g_enters_interactive_mode_from_center_pane() {
        let mut app = test_app(default_bindings());
        app.focus = FocusPane::Center;
        app.center_mode = CenterMode::Agent;
        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn(
                "sh",
                &["-c".to_string(), "printf ready; sleep 0.2".to_string()],
                std::path::Path::new("."),
                10,
                10,
                100,
            )
            .expect("spawn pty"),
        );

        // Ctrl+G from non-interactive center pane should enter interactive mode.
        app.handle_key(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL))
            .unwrap();

        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn close_top_overlay_closes_terminal_overlay() {
        let mut app = test_app(default_bindings());

        app.show_companion_terminal()
            .expect("launch companion terminal");

        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Terminal);

        let closed = app.close_top_overlay();
        assert!(closed);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        assert_eq!(app.session_surface, SessionSurface::Agent);
        // Terminal PTY should still be alive in the map.
        assert_eq!(app.companion_terminals.len(), 1);
    }

    #[test]
    fn session_switch_closes_terminal_overlay_but_keeps_pty() {
        let mut app = test_app(default_bindings());

        app.show_companion_terminal()
            .expect("launch companion terminal");

        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Terminal);

        // Switch to project row (index 0) — simulates user clicking a different item.
        app.set_left_selection(0);

        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        // Terminal PTY should still be alive.
        assert_eq!(app.companion_terminals.len(), 1);
    }

    #[test]
    fn terminal_items_returns_running_terminals() {
        let mut app = test_app(default_bindings());

        // No terminals launched yet.
        assert!(app.terminal_items().is_empty());

        // Launch a terminal.
        app.show_companion_terminal()
            .expect("launch companion terminal");

        assert_eq!(app.terminal_items().len(), 1);
    }

    #[test]
    fn left_section_navigation_crosses_to_terminals() {
        let mut app = test_app(default_bindings());
        app.show_companion_terminal()
            .expect("launch companion terminal");
        // Close overlay and go back to projects section.
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.input_target = InputTarget::None;
        app.session_surface = SessionSurface::Agent;
        app.left_section = LeftSection::Projects;
        app.focus = FocusPane::Left;

        // Navigate down past the last project item.
        let item_count = app.left_items().len();
        app.selected_left = item_count - 1;

        let down = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
        app.handle_key(down).unwrap();

        assert_eq!(app.left_section, LeftSection::Terminals);
        assert_eq!(app.selected_terminal_index, 0);
    }

    #[test]
    fn header_running_terminal_count() {
        let mut app = test_app(default_bindings());

        assert_eq!(app.running_companion_terminal_count(), 0);

        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let (command, args) = ("/bin/sh", vec!["-c".to_string(), "sleep 2".to_string()]);
        let client = PtyClient::spawn(command, &args, worktree, 24, 80, 1_000).expect("spawn");
        app.companion_terminals.insert(
            "term-1".to_string(),
            crate::app::CompanionTerminal {
                session_id,
                label: "test".to_string(),
                foreground_cmd: None,
                client,
            },
        );

        assert_eq!(app.running_companion_terminal_count(), 1);
    }

    #[test]
    fn show_or_open_first_terminal_spawns_when_none_exist() {
        let mut app = test_app(default_bindings());

        assert!(app.companion_terminals.is_empty());

        app.show_or_open_first_terminal()
            .expect("should spawn new terminal");

        assert_eq!(app.companion_terminals.len(), 1);
        assert!(app.active_terminal_id.is_some());
        assert_eq!(app.session_surface, SessionSurface::Terminal);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Terminal);
        assert_eq!(app.input_target, InputTarget::Terminal);
        // Spawned via fallback — returns to terminal list on close.
        assert!(app.terminal_return_to_list);
    }

    #[test]
    fn show_or_open_first_terminal_reuses_existing() {
        let mut app = test_app(default_bindings());

        // Spawn an initial terminal.
        app.show_companion_terminal().expect("spawn first");
        let first_id = app.active_terminal_id.clone().unwrap();
        assert_eq!(app.companion_terminals.len(), 1);

        // Close overlay to simulate returning to normal view.
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.input_target = InputTarget::None;
        app.session_surface = SessionSurface::Agent;

        // Now use the "open first" method — it should reuse, not spawn.
        app.show_or_open_first_terminal()
            .expect("should reuse existing terminal");

        assert_eq!(app.companion_terminals.len(), 1, "no new terminal spawned");
        assert_eq!(app.active_terminal_id.as_deref(), Some(first_id.as_str()));
        assert_eq!(app.session_surface, SessionSurface::Terminal);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Terminal);
        assert_eq!(app.input_target, InputTarget::Terminal);
        // Reused — should NOT return to terminal list on close.
        assert!(!app.terminal_return_to_list);
    }

    #[test]
    fn show_or_open_first_terminal_picks_lowest_id() {
        let mut app = test_app(default_bindings());

        // Spawn two terminals.
        app.show_companion_terminal().expect("first");
        let first_id = app.active_terminal_id.clone().unwrap();
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.input_target = InputTarget::None;
        app.session_surface = SessionSurface::Agent;

        app.show_companion_terminal().expect("second");
        let second_id = app.active_terminal_id.clone().unwrap();
        assert_ne!(first_id, second_id);
        assert_eq!(app.companion_terminals.len(), 2);

        // Close overlay.
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.input_target = InputTarget::None;
        app.session_surface = SessionSurface::Agent;

        // Should pick the first (lowest ID), not the second.
        app.show_or_open_first_terminal()
            .expect("should reuse first");

        assert_eq!(app.active_terminal_id.as_deref(), Some(first_id.as_str()));
        assert_eq!(app.companion_terminals.len(), 2, "still two terminals");
    }

    #[test]
    fn spawn_terminal_for_selected_terminal_creates_new() {
        let mut app = test_app(default_bindings());

        // Spawn an initial terminal so the terminals list is populated.
        app.show_companion_terminal().expect("initial terminal");
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.input_target = InputTarget::None;
        app.session_surface = SessionSurface::Agent;

        // Point the terminal cursor at the first terminal.
        app.left_section = LeftSection::Terminals;
        app.selected_terminal_index = 0;

        let initial_count = app.companion_terminals.len();
        app.spawn_terminal_for_selected_terminal()
            .expect("spawn from terminals pane");

        assert_eq!(
            app.companion_terminals.len(),
            initial_count + 1,
            "a new terminal was created"
        );
        assert_eq!(app.session_surface, SessionSurface::Terminal);
        assert_eq!(app.input_target, InputTarget::Terminal);
        assert!(app.terminal_return_to_list);
    }

    #[test]
    fn new_companion_terminal_warns_without_selected_session() {
        let mut app = test_app(default_bindings());

        // Deselect everything by pointing at a project header.
        app.selected_left = 0;

        app.new_companion_terminal()
            .expect("should not error, just warn");

        assert_eq!(
            app.status.tone(),
            crate::statusline::StatusTone::Warning,
            "should show yellow warning, not red error"
        );
        assert!(app.status.text().contains("Select an agent session"));
        assert!(
            app.companion_terminals.is_empty(),
            "no terminal should be spawned"
        );
    }

    #[test]
    fn new_companion_terminal_spawns_when_session_selected() {
        let mut app = test_app(default_bindings());

        app.new_companion_terminal()
            .expect("should spawn new terminal");

        assert_eq!(app.companion_terminals.len(), 1);
        assert!(app.active_terminal_id.is_some());
        assert_eq!(app.input_target, InputTarget::Terminal);
    }

    #[test]
    fn mouse_click_discard_dialog_button_discards_changes() {
        let mut app = test_app(default_bindings());
        install_mouse_layout(&mut app);
        init_git_repo_with_modified_file(
            &app,
            "src/main.rs",
            "fn main() {}\n",
            "fn main() { println!(\"hi\"); }\n",
        );
        app.selected_left = 1;
        app.unstaged_files = vec![ChangedFile {
            path: "src/main.rs".into(),
            status: "M".into(),
            additions: 1,
            deletions: 1,
            binary: false,
        }];
        app.prompt = PromptState::ConfirmDiscardFile {
            file_path: "src/main.rs".to_string(),
            is_untracked: false,
            confirm_selected: true,
        };
        install_confirm_discard_overlay(&mut app);

        // Down+Up at the same coordinates fires the button action.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 53, 10));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 53, 10));

        assert!(matches!(app.prompt, PromptState::None));
        let contents = std::fs::read_to_string(
            PathBuf::from(&app.sessions[0].worktree_path).join("src/main.rs"),
        )
        .expect("discarded file");
        assert_eq!(contents, "fn main() {}\n");
    }

    #[test]
    fn mouse_click_delete_dialog_cancel_button_closes_prompt() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmDeleteAgent {
            session_id: app.sessions[0].id.clone(),
            branch_name: app.sessions[0].branch_name.clone(),
            focus: DeleteAgentFocus::Cancel,
            delete_worktree: false,
            worktree_shared: false,
        };
        install_confirm_delete_overlay(&mut app);

        // Down+Up at the same coordinates fires the Cancel button.
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 35, 10));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 35, 10));

        assert!(matches!(app.prompt, PromptState::None));
    }

    #[test]
    fn mouse_click_delete_dialog_checkbox_toggles_delete_worktree() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmDeleteAgent {
            session_id: app.sessions[0].id.clone(),
            branch_name: app.sessions[0].branch_name.clone(),
            focus: DeleteAgentFocus::Cancel,
            delete_worktree: false,
            worktree_shared: false,
        };
        install_confirm_delete_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 7));

        match &app.prompt {
            PromptState::ConfirmDeleteAgent {
                delete_worktree,
                focus,
                ..
            } => {
                assert!(*delete_worktree);
                assert_eq!(*focus, DeleteAgentFocus::Checkbox);
            }
            other => panic!("expected delete prompt, got {other:?}"),
        }
    }

    #[test]
    fn delete_agent_dialog_spaces_checkbox_from_buttons() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmDeleteAgent {
            session_id: app.sessions[0].id.clone(),
            branch_name: app.sessions[0].branch_name.clone(),
            focus: DeleteAgentFocus::Cancel,
            delete_worktree: false,
            worktree_shared: false,
        };

        let backend = TestBackend::new(120, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");

        let (checkbox, cancel_button) = match app.overlay_layout.active {
            OverlayMouseLayout::ConfirmDeleteAgent {
                checkbox: Some(checkbox),
                cancel_button,
                ..
            } => (checkbox.rect, cancel_button),
            other => panic!("expected delete-agent overlay layout, got {other:?}"),
        };
        let spacer_y = checkbox.y + checkbox.height;

        assert_eq!(
            cancel_button.y,
            spacer_y + 1,
            "buttons should start one blank row below the checkbox"
        );
        assert_eq!(
            terminal
                .backend()
                .buffer()
                .cell((cancel_button.x, spacer_y))
                .expect("spacer cell")
                .symbol(),
            " ",
            "expected blank space between checkbox and buttons"
        );
    }

    #[test]
    fn shift_tab_moves_delete_agent_focus_backwards() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmDeleteAgent {
            session_id: app.sessions[0].id.clone(),
            branch_name: app.sessions[0].branch_name.clone(),
            focus: DeleteAgentFocus::Cancel,
            delete_worktree: false,
            worktree_shared: false,
        };

        app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmDeleteAgent { focus, .. } => {
                assert_eq!(
                    *focus,
                    DeleteAgentFocus::Checkbox,
                    "Shift-Tab should move backward from Cancel to the checkbox"
                );
            }
            other => panic!("expected delete-agent confirmation, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmDeleteAgent { focus, .. } => {
                assert_eq!(
                    *focus,
                    DeleteAgentFocus::Delete,
                    "Shift-Tab should continue backward from the checkbox to Delete"
                );
            }
            other => panic!("expected delete-agent confirmation, got {other:?}"),
        }
    }

    #[test]
    fn tab_with_shift_moves_delete_agent_focus_backwards() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmDeleteAgent {
            session_id: app.sessions[0].id.clone(),
            branch_name: app.sessions[0].branch_name.clone(),
            focus: DeleteAgentFocus::Cancel,
            delete_worktree: false,
            worktree_shared: false,
        };

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmDeleteAgent { focus, .. } => {
                assert_eq!(
                    *focus,
                    DeleteAgentFocus::Checkbox,
                    "Shift-Tab delivered as Tab + Shift should move backward to the checkbox"
                );
            }
            other => panic!("expected delete-agent confirmation, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmDeleteAgent { focus, .. } => {
                assert_eq!(
                    *focus,
                    DeleteAgentFocus::Delete,
                    "Tab + Shift should continue backward from the checkbox to Delete"
                );
            }
            other => panic!("expected delete-agent confirmation, got {other:?}"),
        }
    }

    #[test]
    fn tab_with_shift_moves_non_default_branch_focus_backwards_when_checkbox_present() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmNonDefaultBranch {
            action: NonDefaultBranchAction::AddProject {
                path: "/tmp/project".to_string(),
                name: "project".to_string(),
                leading_branch: "main".to_string(),
            },
            current_branch: "feature".to_string(),
            kind: BranchWarningKind::Known {
                default_branch: "main".to_string(),
            },
            focus: ConfirmNonDefaultBranchFocus::Cancel,
            checkout_default: true,
        };

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmNonDefaultBranch { focus, .. } => {
                assert_eq!(
                    *focus,
                    ConfirmNonDefaultBranchFocus::Checkbox,
                    "Tab + Shift should move backward from Cancel to the checkbox"
                );
            }
            other => panic!("expected non-default-branch confirmation, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmNonDefaultBranch { focus, .. } => {
                assert_eq!(
                    *focus,
                    ConfirmNonDefaultBranchFocus::Add,
                    "Tab + Shift should continue backward from the checkbox to Add"
                );
            }
            other => panic!("expected non-default-branch confirmation, got {other:?}"),
        }
    }

    #[test]
    fn tab_with_shift_moves_non_default_branch_focus_backwards_without_checkbox() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::ConfirmNonDefaultBranch {
            action: NonDefaultBranchAction::AddProject {
                path: "/tmp/project".to_string(),
                name: "project".to_string(),
                leading_branch: "feature".to_string(),
            },
            current_branch: "feature".to_string(),
            kind: BranchWarningKind::Heuristic,
            focus: ConfirmNonDefaultBranchFocus::Cancel,
            checkout_default: false,
        };

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmNonDefaultBranch { focus, .. } => {
                assert_eq!(
                    *focus,
                    ConfirmNonDefaultBranchFocus::Add,
                    "Tab + Shift should reverse-toggle between the two buttons when no checkbox exists"
                );
            }
            other => panic!("expected non-default-branch confirmation, got {other:?}"),
        }

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT))
            .unwrap();

        match &app.prompt {
            PromptState::ConfirmNonDefaultBranch { focus, .. } => {
                assert_eq!(
                    *focus,
                    ConfirmNonDefaultBranchFocus::Cancel,
                    "A second Tab + Shift should cycle back to Cancel"
                );
            }
            other => panic!("expected non-default-branch confirmation, got {other:?}"),
        }
    }

    #[test]
    fn mouse_click_rename_input_moves_cursor() {
        let mut app = test_app(default_bindings());
        let sid = app.sessions[0].id.clone();
        app.prompt = PromptState::RenameSession {
            session_id: sid,
            input: TextInput::with_text("rename me".to_string()),
            rename_branch: false,
        };
        install_rename_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 29, 10));

        match &app.prompt {
            PromptState::RenameSession { input, .. } => assert_eq!(input.cursor, 5),
            _ => panic!("expected rename prompt"),
        }
    }

    #[test]
    fn mouse_click_rename_checkbox_toggles_branch_rename() {
        let mut app = test_app(default_bindings());
        let sid = app.sessions[0].id.clone();
        app.prompt = PromptState::RenameSession {
            session_id: sid,
            input: TextInput::with_text("rename me".to_string()),
            rename_branch: false,
        };
        install_rename_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 12));

        match &app.prompt {
            PromptState::RenameSession { rename_branch, .. } => assert!(*rename_branch),
            other => panic!("expected rename prompt, got {other:?}"),
        }
    }

    #[test]
    fn startup_command_logs_click_outside_closes_modal() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());
        install_startup_command_logs_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0));

        assert!(matches!(app.prompt, PromptState::None));
    }

    #[test]
    fn startup_command_logs_click_inside_does_not_close_modal() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());
        install_startup_command_logs_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 45, 6));

        assert!(matches!(app.prompt, PromptState::StartupCommandLogs(_)));
    }

    #[test]
    fn startup_command_logs_scroll_wheel_moves_output() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());
        install_startup_command_logs_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::ScrollDown, 45, 6));

        match &app.prompt {
            PromptState::StartupCommandLogs(prompt) => assert_eq!(prompt.scroll_offset, 3),
            other => panic!("expected startup logs prompt, got {other:?}"),
        }
    }

    #[test]
    fn startup_command_logs_scroll_clamps_at_end() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());
        install_startup_command_logs_overlay(&mut app, 1);

        for _ in 0..20 {
            app.handle_mouse(mouse(MouseEventKind::ScrollDown, 45, 6));
        }

        match &app.prompt {
            PromptState::StartupCommandLogs(prompt) => assert_eq!(prompt.scroll_offset, 18),
            other => panic!("expected startup logs prompt, got {other:?}"),
        }
    }

    #[test]
    fn startup_command_logs_page_scroll_uses_output_height() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());
        install_startup_command_logs_overlay(&mut app, 1);

        app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE))
            .unwrap();

        match &app.prompt {
            PromptState::StartupCommandLogs(prompt) => assert_eq!(prompt.scroll_offset, 12),
            other => panic!("expected startup logs prompt, got {other:?}"),
        }
    }

    #[test]
    fn startup_command_logs_close_button_closes_modal() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());
        install_startup_command_logs_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 45, 19));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 45, 19));

        assert!(matches!(app.prompt, PromptState::None));
    }

    #[test]
    fn startup_command_logs_drag_selection_copies_text() {
        let mut app = test_app(default_bindings());
        app.clipboard = Clipboard::from_fn(clipboard_ok);
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());
        install_startup_command_logs_overlay(&mut app, 2);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 44, 5));
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 52, 5));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 52, 5));
        drain_until(&mut app, |app| {
            app.status.text() == "Startup command log text copied to clipboard."
        });

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Info);
        assert_eq!(
            app.status.text(),
            "Startup command log text copied to clipboard."
        );
    }

    #[test]
    fn startup_command_logs_single_click_output_does_not_crash() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());

        let backend = TestBackend::new(120, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");

        let body = match app.overlay_layout.active {
            OverlayMouseLayout::StartupCommandLogs { body, .. } => body,
            other => panic!("expected startup logs overlay, got {other:?}"),
        };
        let x = body.x;
        let y = body.y;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame after down");
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), x, y));
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame after up");

        assert!(matches!(app.prompt, PromptState::StartupCommandLogs(_)));
        assert!(app.startup_log_selection.is_none());
    }

    #[test]
    fn startup_command_logs_prompt_disables_raw_input_routing() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());

        assert!(!app.should_poll_raw_input());
    }

    #[test]
    fn startup_command_logs_single_click_run_does_not_crash() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());

        let backend = TestBackend::new(120, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");

        let list = match app.overlay_layout.active {
            OverlayMouseLayout::StartupCommandLogs { list, .. } => list,
            other => panic!("expected startup logs overlay, got {other:?}"),
        };
        let x = list.x;
        let y = list.y;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), x, y));
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame after row click");

        assert!(matches!(app.prompt, PromptState::StartupCommandLogs(_)));
    }

    #[test]
    fn startup_command_logs_slash_filters_runs() {
        let mut app = test_app(default_bindings());
        app.prompt = PromptState::StartupCommandLogs(startup_command_logs_prompt());
        install_startup_command_logs_overlay(&mut app, 2);

        app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
            .unwrap();
        for ch in "install".chars() {
            app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
                .unwrap();
        }

        match &app.prompt {
            PromptState::StartupCommandLogs(prompt) => {
                assert!(prompt.searching);
                assert_eq!(prompt.filter.text, "install");
                assert_eq!(prompt.selected, 1);
            }
            other => panic!("expected startup logs prompt, got {other:?}"),
        }
    }

    #[test]
    fn read_startup_command_logs_opens_latest_log_fullscreen() {
        let mut app = test_app(default_bindings());
        let log_dir = crate::startup::agent_log_dir(&app.paths, "project-1", "session-1");
        std::fs::create_dir_all(&log_dir).expect("log dir");
        std::fs::write(log_dir.join("20260515T010000Z-old.log"), "old log").expect("old log");
        std::fs::write(log_dir.join("20260515T020000Z-new.log"), "new log").expect("new log");

        app.open_startup_command_logs().expect("open logs");
        drain_until(&mut app, |app| app.startup_log_viewer.is_some());

        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::StartupLog);
        let viewer = app.startup_log_viewer.as_ref().expect("log viewer");
        assert_eq!(viewer.display_name, "20260515T020000Z-new.log");
        assert_eq!(viewer.content, "new log");
    }

    #[test]
    fn startup_log_fullscreen_search_scrolls_to_match() {
        let mut app = test_app(default_bindings());
        app.startup_log_viewer = Some(crate::app::StartupLogViewer {
            scope_label: "project \"demo\"".to_string(),
            path: None,
            display_name: "startup.log".to_string(),
            content: (0..30)
                .map(|line| {
                    if line == 20 {
                        "needle line".to_string()
                    } else {
                        format!("ordinary line {line}")
                    }
                })
                .collect::<Vec<_>>()
                .join("\n"),
            scroll_offset: 0,
            search: TextInput::new(),
            searching: false,
        });
        app.fullscreen_overlay = FullscreenOverlay::StartupLog;
        app.mouse_layout.agent_term = Some(Rect::new(0, 0, 80, 10));

        app.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE))
            .unwrap();
        for ch in "needle".chars() {
            app.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE))
                .unwrap();
        }

        let viewer = app.startup_log_viewer.as_ref().expect("log viewer");
        assert!(viewer.searching);
        assert_eq!(viewer.search.text, "needle");
        assert_eq!(viewer.scroll_offset, 20);
    }

    #[test]
    fn startup_log_fullscreen_drag_selection_copies_text() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        app.clipboard = Clipboard::from_fn(clipboard_ok);
        app.startup_log_viewer = Some(crate::app::StartupLogViewer {
            scope_label: "project \"demo\"".to_string(),
            path: None,
            display_name: "startup.log".to_string(),
            content: "first line\nsecond line".to_string(),
            scroll_offset: 0,
            search: TextInput::new(),
            searching: false,
        });
        app.fullscreen_overlay = FullscreenOverlay::StartupLog;

        let backend = TestBackend::new(120, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");
        let body = app.mouse_layout.agent_term.expect("log body");

        app.handle_mouse(mouse(
            MouseEventKind::Down(MouseButton::Left),
            body.x,
            body.y,
        ));
        app.handle_mouse(mouse(
            MouseEventKind::Drag(MouseButton::Left),
            body.x + 4,
            body.y,
        ));
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame with selection");
        app.handle_mouse(mouse(
            MouseEventKind::Up(MouseButton::Left),
            body.x + 4,
            body.y,
        ));
        drain_until(&mut app, |app| {
            app.status.text() == "Startup command log text copied to clipboard."
        });

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Info);
        assert_eq!(
            app.status.text(),
            "Startup command log text copied to clipboard."
        );
    }

    #[test]
    fn mouse_click_name_prompt_checkbox_toggles_randomized_name() {
        let mut app = test_app(default_bindings());
        app.create_agent_for_selected_project().unwrap();
        complete_create_agent_branch_inspection(&mut app, "main", "main");
        install_name_new_agent_overlay(&mut app);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 30, 12));

        match &app.prompt {
            PromptState::NameNewAgent {
                input,
                randomize_name,
                randomized_name,
                focus,
                ..
            } => {
                assert!(*randomize_name);
                assert_eq!(*focus, NameNewAgentFocus::RandomizedNameCheckbox);
                assert!(!input.text.is_empty());
                assert_eq!(randomized_name.as_deref(), Some(input.text.as_str()));
            }
            other => panic!("expected name-new-agent prompt, got {other:?}"),
        }
    }

    #[test]
    fn mouse_click_while_prompt_open_does_not_change_underlying_focus() {
        let mut app = test_app(default_bindings());
        app.focus = FocusPane::Left;
        app.prompt = PromptState::Command {
            input: TextInput::with_text("help".to_string()),
            selected: 0,
        };
        install_command_overlay(&mut app, 1);

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 0, 0));

        assert_eq!(app.focus, FocusPane::Left);
        assert!(matches!(app.prompt, PromptState::Command { .. }));
    }

    #[test]
    fn toggle_git_pane_collapses_right() {
        let mut app = test_app(default_bindings());
        assert!(!app.right_collapsed);

        app.handle_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE))
            .unwrap();
        assert!(app.right_collapsed);

        app.handle_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE))
            .unwrap();
        assert!(!app.right_collapsed);
    }

    #[test]
    fn collapse_git_pane_moves_focus_from_files() {
        let mut app = test_app(default_bindings());
        app.focus = FocusPane::Files;

        app.handle_key(KeyEvent::new(KeyCode::Char(']'), KeyModifiers::NONE))
            .unwrap();

        assert!(app.right_collapsed);
        assert_eq!(app.focus, FocusPane::Center);
    }

    #[test]
    fn remove_git_pane_hides_right() {
        let mut app = test_app(default_bindings());
        assert!(!app.right_hidden);

        app.execute_command("toggle-remove-git-pane".to_string())
            .unwrap();
        assert!(app.right_hidden);

        app.execute_command("toggle-remove-git-pane".to_string())
            .unwrap();
        assert!(!app.right_hidden);
    }

    #[test]
    fn remove_git_pane_moves_focus_from_files() {
        let mut app = test_app(default_bindings());
        app.focus = FocusPane::Files;

        app.execute_command("toggle-remove-git-pane".to_string())
            .unwrap();

        assert!(app.right_hidden);
        assert_eq!(app.focus, FocusPane::Center);
    }

    #[test]
    fn focus_skips_removed_git_pane_forward() {
        let mut app = test_app(default_bindings());
        app.right_hidden = true;
        app.focus = FocusPane::Center;

        // Tab from Center should skip Files and go to Left
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .unwrap();

        assert_eq!(app.focus, FocusPane::Left);
    }

    #[test]
    fn focus_skips_removed_git_pane_backward() {
        let mut app = test_app(default_bindings());
        app.right_hidden = true;
        app.focus = FocusPane::Left;

        // Shift-Tab from Left should skip Files and go to Center
        app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT))
            .unwrap();

        assert_eq!(app.focus, FocusPane::Center);
    }

    #[test]
    fn resume_fallback_retries_with_fresh_session_on_quick_exit() {
        let mut app = test_app(default_bindings());
        // Override the "codex" provider to use /bin/sh so the fallback spawn
        // works on CI where codex is not installed.
        app.config.providers.commands.insert(
            "codex".to_string(),
            crate::config::ProviderCommandConfig {
                command: "/bin/sh".to_string(),
                ..Default::default()
            },
        );
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        // Spawn a process that exits immediately without producing output.
        let args = vec!["-c".to_string(), "exit 1".to_string()];
        let client =
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn quick-exit");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_status(&session_id, SessionStatus::Active);
        app.resume_fallback_candidates
            .insert(session_id.clone(), std::time::Instant::now());
        app.selected_left = 1;
        app.session_surface = SessionSurface::Agent;

        // Wait for the process to exit.
        std::thread::sleep(std::time::Duration::from_millis(200));
        drain_until(&mut app, |app| {
            app.providers.contains_key(&session_id)
                && !app.agent_launches_in_flight.contains(&session_id)
                && app.status.text().contains("No prior session to resume")
        });

        // The fallback should have spawned a fresh session, so the provider
        // is still present and the session is active (not detached).
        assert!(
            app.providers.contains_key(&session_id),
            "provider should still be present after fallback retry"
        );
        assert_eq!(app.sessions[0].status, SessionStatus::Active);
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
        assert!(
            !app.resume_fallback_candidates.contains_key(&session_id),
            "candidate should have been removed after fallback"
        );
        assert!(
            app.status.text().contains("No prior session to resume"),
            "status should inform user about fallback: {:?}",
            app.status.text()
        );
    }

    #[test]
    fn resume_fallback_skipped_when_pty_had_substantial_output() {
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        // Spawn a process that produces many lines of output before exiting.
        // This simulates a real agent session that ran and then exited normally.
        let args = vec!["-c".to_string(), "seq 1 30".to_string()];
        let client =
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn with output");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_status(&session_id, SessionStatus::Active);
        app.resume_fallback_candidates
            .insert(session_id.clone(), std::time::Instant::now());
        app.selected_left = 1;
        app.session_surface = SessionSurface::Agent;

        // Wait for the process to produce output and exit.
        std::thread::sleep(std::time::Duration::from_millis(200));
        drain_until(&mut app, |app| {
            app.sessions[0].status == SessionStatus::Detached
        });

        // Since the PTY had substantial output (>5 lines), the fallback
        // should NOT have triggered. The session should be detached.
        assert_eq!(app.sessions[0].status, SessionStatus::Detached);
        assert!(
            !app.resume_fallback_candidates.contains_key(&session_id),
            "candidate should have been removed even when skipped"
        );
    }

    #[test]
    fn resume_fallback_triggers_on_one_liner_output() {
        let mut app = test_app(default_bindings());
        // Override the "codex" provider to use /bin/sh so the fallback spawn
        // works on CI where codex is not installed.
        app.config.providers.commands.insert(
            "codex".to_string(),
            crate::config::ProviderCommandConfig {
                command: "/bin/sh".to_string(),
                ..Default::default()
            },
        );
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        // Spawn a process that prints a single line (like a failed --continue)
        // and then exits. The fallback should still trigger because the output
        // is minimal (≤5 lines with no scrollback).
        let args = vec![
            "-c".to_string(),
            "echo 'No session found to resume'".to_string(),
        ];
        let client =
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn one-liner");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_status(&session_id, SessionStatus::Active);
        app.resume_fallback_candidates
            .insert(session_id.clone(), std::time::Instant::now());
        app.selected_left = 1;
        app.session_surface = SessionSurface::Agent;

        // Wait for the process to produce its one line and exit.
        std::thread::sleep(std::time::Duration::from_millis(200));
        drain_until(&mut app, |app| {
            app.providers.contains_key(&session_id)
                && !app.agent_launches_in_flight.contains(&session_id)
                && app.status.text().contains("No prior session to resume")
        });

        // Despite having output, the fallback should trigger because the
        // output is minimal (one line, no scrollback).
        assert!(
            app.providers.contains_key(&session_id),
            "provider should still be present after fallback retry"
        );
        assert_eq!(app.sessions[0].status, SessionStatus::Active);
        assert!(
            app.status.text().contains("No prior session to resume"),
            "status should inform user about fallback: {:?}",
            app.status.text()
        );
    }

    #[test]
    fn no_fallback_for_non_candidate_sessions() {
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        // Spawn a process that exits immediately, but do NOT add it as a candidate.
        let args = vec!["-c".to_string(), "exit 1".to_string()];
        let client =
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn quick-exit");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_status(&session_id, SessionStatus::Active);
        // Deliberately not adding to resume_fallback_candidates.
        app.selected_left = 1;
        app.session_surface = SessionSurface::Agent;

        std::thread::sleep(std::time::Duration::from_millis(200));
        drain_until(&mut app, |app| !app.providers.contains_key(&session_id));

        // Without being a candidate, the session should just go to detached.
        assert!(
            !app.providers.contains_key(&session_id),
            "provider should have been removed"
        );
        assert_eq!(app.sessions[0].status, SessionStatus::Detached);
    }

    #[test]
    fn zero_exit_clears_desired_running() {
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let args = vec!["-c".to_string(), "exit 0".to_string()];
        let client =
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn zero-exit");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_desired_running(&session_id, true);
        app.mark_session_status(&session_id, SessionStatus::Active);

        std::thread::sleep(std::time::Duration::from_millis(200));
        drain_until(&mut app, |app| !app.providers.contains_key(&session_id));

        assert!(!app.sessions[0].desired_running);
        let loaded = app.session_store.load_sessions().expect("load sessions");
        assert!(!loaded[0].desired_running);
    }

    #[test]
    fn nonzero_exit_keeps_desired_running() {
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let args = vec!["-c".to_string(), "exit 1".to_string()];
        let client = PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000)
            .expect("spawn nonzero-exit");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_desired_running(&session_id, true);
        app.mark_session_status(&session_id, SessionStatus::Active);

        std::thread::sleep(std::time::Duration::from_millis(200));
        drain_until(&mut app, |app| !app.providers.contains_key(&session_id));

        assert!(app.sessions[0].desired_running);
        let loaded = app.session_store.load_sessions().expect("load sessions");
        assert!(loaded[0].desired_running);
    }

    #[test]
    fn quick_nonzero_exit_reports_minimal_output() {
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let args = vec![
            "-c".to_string(),
            "echo 'custom provider failed'; exit 1".to_string(),
        ];
        let client = PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000)
            .expect("spawn nonzero-output");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_desired_running(&session_id, true);
        app.mark_session_status(&session_id, SessionStatus::Active);
        app.selected_left = 1;
        app.session_surface = SessionSurface::Agent;

        std::thread::sleep(std::time::Duration::from_millis(200));
        drain_until(&mut app, |app| {
            app.status.text().contains("custom provider failed")
        });

        assert_eq!(app.status.tone(), crate::statusline::StatusTone::Error);
        assert!(
            app.status.text().contains("custom provider failed"),
            "status should include provider output, got: {:?}",
            app.status.text()
        );
    }

    #[test]
    fn startup_auto_reopens_eligible_sessions() {
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        app.config.providers.commands.insert(
            "codex".to_string(),
            crate::config::ProviderCommandConfig {
                command: "/bin/sh".to_string(),
                args: Vec::new(),
                resume_args: Some(vec!["-c".to_string(), "sleep 5".to_string()]),
                ..Default::default()
            },
        );
        app.config.ui.auto_reopen_agents = true;
        app.sessions[0].desired_running = true;

        app.restore_sessions();
        drain_until(&mut app, |app| {
            app.providers.contains_key(&session_id)
                && !app.agent_launches_in_flight.contains(&session_id)
                && app.sessions[0].status == SessionStatus::Active
        });

        assert!(app.providers.contains_key(&session_id));
        assert_eq!(app.sessions[0].status, SessionStatus::Active);
    }

    #[test]
    fn startup_auto_reopen_respects_global_project_and_agent_opt_outs() {
        let mut app = test_app(default_bindings());
        app.config.providers.commands.insert(
            "codex".to_string(),
            crate::config::ProviderCommandConfig {
                command: "/bin/sh".to_string(),
                resume_args: Some(vec!["-c".to_string(), "sleep 5".to_string()]),
                ..Default::default()
            },
        );
        app.sessions[0].desired_running = true;

        app.config.ui.auto_reopen_agents = false;
        app.restore_sessions();
        assert!(app.providers.is_empty());

        app.config.ui.auto_reopen_agents = true;
        app.projects[0].auto_reopen_agents = Some(false);
        app.restore_sessions();
        assert!(app.providers.is_empty());

        app.projects[0].auto_reopen_agents = None;
        app.sessions[0].auto_reopen_enabled = false;
        app.restore_sessions();
        assert!(app.providers.is_empty());
    }

    #[test]
    fn hung_resume_falls_back_to_fresh_session_once() {
        let mut app = test_app(default_bindings());
        app.config.providers.commands.insert(
            "opencode".to_string(),
            crate::config::ProviderCommandConfig {
                command: "/bin/sh".to_string(),
                args: vec!["-c".to_string(), "sleep 5".to_string()],
                resume_args: Some(vec!["-c".to_string(), "sleep 5".to_string()]),
                resume_wait_timeout_ms: Some(10),
                ..Default::default()
            },
        );
        app.sessions[0].provider = ProviderKind::from_str("opencode");
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let args = vec!["-c".to_string(), "sleep 5".to_string()];
        let client =
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn hung resume");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_status(&session_id, SessionStatus::Active);
        app.resume_fallback_candidates.insert(
            session_id.clone(),
            std::time::Instant::now() - std::time::Duration::from_millis(20),
        );
        app.selected_left = 1;
        app.session_surface = SessionSurface::Agent;

        drain_until(&mut app, |app| {
            app.providers.contains_key(&session_id)
                && !app.agent_launches_in_flight.contains(&session_id)
                && app.status.text().contains("Resume timed out")
        });

        assert!(
            app.providers.contains_key(&session_id),
            "provider should still be present after timeout fallback"
        );
        assert_eq!(app.sessions[0].status, SessionStatus::Active);
        assert!(
            !app.resume_fallback_candidates.contains_key(&session_id),
            "candidate should have been removed after timeout fallback"
        );
        assert!(
            app.status.text().contains("Resume timed out"),
            "status should inform user about timeout fallback: {:?}",
            app.status.text()
        );
    }

    #[test]
    fn hung_fresh_session_does_not_retry_forever() {
        let mut app = test_app(default_bindings());
        app.config.providers.commands.insert(
            "opencode".to_string(),
            crate::config::ProviderCommandConfig {
                command: "/bin/sh".to_string(),
                args: vec!["-c".to_string(), "sleep 5".to_string()],
                resume_args: Some(vec!["-c".to_string(), "sleep 5".to_string()]),
                resume_wait_timeout_ms: Some(10),
                ..Default::default()
            },
        );
        app.sessions[0].provider = ProviderKind::from_str("opencode");
        let session_id = app.sessions[0].id.clone();
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let args = vec!["-c".to_string(), "sleep 5".to_string()];
        let client =
            PtyClient::spawn("/bin/sh", &args, worktree, 24, 80, 1_000).expect("spawn fresh hang");
        app.providers.insert(session_id.clone(), client);
        app.mark_session_status(&session_id, SessionStatus::Active);

        app.drain_events();
        std::thread::sleep(std::time::Duration::from_millis(30));
        app.drain_events();

        assert!(app.providers.contains_key(&session_id));
        assert!(app.resume_fallback_candidates.is_empty());
    }

    // ── Scroll-mode input suppression tests ─────────────────────────

    /// Helper: set up an App with a live PTY in interactive mode and
    /// enough scrollback history so we can engage scroll mode.
    fn app_with_scrolled_back_pty() -> App {
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();

        // Spawn a shell that prints enough lines to fill the 5-row terminal
        // and produce scrollback history, then sleeps to stay alive.
        let args = vec![
            "-c".to_string(),
            "printf 'L1\\nL2\\nL3\\nL4\\nL5\\nL6\\nL7\\nL8\\nL9\\nL10\\n'; sleep 5".to_string(),
        ];
        let client = PtyClient::spawn("sh", &args, std::path::Path::new("."), 5, 40, 100)
            .expect("spawn pty");
        app.providers.insert(session_id, client);

        // Enter interactive agent mode.
        app.input_target = InputTarget::Agent;
        app.session_surface = SessionSurface::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.last_pty_size = (5, 40);

        // Wait for the child to produce output so the PTY has content.
        std::thread::sleep(std::time::Duration::from_millis(200));

        // Scroll back so scrollback_offset > 0.
        let provider = app.selected_terminal_surface_client().unwrap();
        provider.set_scrollback(3);
        assert!(
            provider.scrollback_offset() > 0,
            "test setup: should be scrolled back"
        );

        app
    }

    #[test]
    fn scrolled_back_suppresses_regular_key_forwarding() {
        let mut app = app_with_scrolled_back_pty();
        // Feed a regular ASCII character 'x' (0x78) — should be dropped.
        let result = app.process_raw_input_bytes(b"x").unwrap();
        assert!(!result, "should not request exit");
        // The key was consumed without error; the PTY didn't receive it.
        // We can't directly assert write_bytes wasn't called without a mock,
        // but we verify the app didn't crash and the scrollback is unchanged.
        assert!(
            app.selected_terminal_surface_client()
                .unwrap()
                .scrollback_offset()
                > 0,
            "scrollback should remain engaged"
        );
    }

    #[test]
    fn scrolled_back_allows_exit_interactive() {
        let mut app = app_with_scrolled_back_pty();
        assert_eq!(app.input_target, InputTarget::Agent);

        // Feed Ctrl-G (0x07) — ExitInteractive should still work.
        let result = app.process_raw_input_bytes(&[0x07]).unwrap();
        assert!(!result);
        assert_eq!(
            app.input_target,
            InputTarget::None,
            "ExitInteractive must work even when scrolled back"
        );
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
    }

    /// Deterministically wait until the agent PTY child has parked its cursor
    /// at the given (row, col), polling the live snapshot instead of guessing a
    /// fixed sleep. Requires `session_surface == Agent` and a selected session
    /// whose provider is in `app.providers`. Panics with the observed cursor if
    /// the child does not reach the expected position within ~2s.
    fn wait_for_agent_cursor(app: &mut App, row: u16, col: u16) {
        for _ in 0..200 {
            app.refresh_snapshot_buf();
            if matches!(app.snapshot_buf.cursor, Some(c) if c.row == row && c.col == col) {
                return;
            }
            std::thread::sleep(std::time::Duration::from_millis(10));
        }
        panic!(
            "PTY did not park its cursor at row {row}, col {col} within 2s (got {:?})",
            app.snapshot_buf.cursor
        );
    }

    /// Regression test for issue #258: while the interactive agent terminal is
    /// rendered, the real (hardware) terminal cursor must be moved onto the
    /// embedded PTY cursor cell. IME composition popups (e.g. a Korean IME) are
    /// drawn by the terminal/OS at the hardware cursor position, so if it is
    /// left at the origin the composing character appears detached from the
    /// prompt near the top-left instead of at the agent's cursor.
    #[test]
    fn interactive_agent_aligns_hardware_cursor_with_pty_cursor() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();

        // `ESC[5;9H` parks the cursor at row 5, col 9 (1-based); printing 'X'
        // then advances it to row 4, col 9 (0-based). `sleep` keeps the child
        // alive so the snapshot keeps a live cursor.
        let args = vec![
            "-c".to_string(),
            "printf '\\033[5;9HX'; sleep 30".to_string(),
        ];
        let client = PtyClient::spawn("/bin/sh", &args, std::path::Path::new("."), 24, 80, 100)
            .expect("spawn pty");
        app.providers.insert(session_id, client);

        // Enter interactive fullscreen agent mode.
        app.input_target = InputTarget::Agent;
        app.session_surface = SessionSurface::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;

        // Wait until the child's cursor escape has been parsed (no fixed sleep).
        wait_for_agent_cursor(&mut app, 4, 9);

        let backend = TestBackend::new(100, 40);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");

        let term_area = app
            .mouse_layout
            .agent_term
            .expect("agent terminal area should be recorded after render");
        // Confirm the renderer's input cursor really is the parked PTY cursor,
        // then derive the expected screen cell from the KNOWN (row 4, col 9)
        // literals — not from snapshot_buf — so a transposed or mis-offset
        // production computation cannot make this assertion tautologically true.
        let cursor = app
            .snapshot_buf
            .cursor
            .expect("interactive agent snapshot should expose a PTY cursor");
        assert_eq!(
            (cursor.row, cursor.col),
            (4, 9),
            "PTY should have parked its cursor at the expected cell"
        );
        let expected = (term_area.x + 9, term_area.y + 4);
        terminal.backend_mut().assert_cursor_position(expected);
    }

    /// The same hardware-cursor alignment must hold in the normal (non-
    /// fullscreen) center-pane agent view, not just the fullscreen overlay.
    #[test]
    fn inline_agent_aligns_hardware_cursor_with_pty_cursor() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let args = vec![
            "-c".to_string(),
            "printf '\\033[5;9HX'; sleep 30".to_string(),
        ];
        let client = PtyClient::spawn("/bin/sh", &args, std::path::Path::new("."), 24, 80, 100)
            .expect("spawn pty");
        app.providers.insert(session_id, client);

        // Inline agent view: no fullscreen overlay, center pane focused.
        app.input_target = InputTarget::Agent;
        app.session_surface = SessionSurface::Agent;
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.center_mode = CenterMode::Agent;
        app.focus = FocusPane::Center;

        wait_for_agent_cursor(&mut app, 4, 9);

        let backend = TestBackend::new(100, 40);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");

        let term_area = app
            .mouse_layout
            .agent_term
            .expect("agent terminal area should be recorded after render");
        let cursor = app
            .snapshot_buf
            .cursor
            .expect("inline agent snapshot should expose a PTY cursor");
        assert_eq!((cursor.row, cursor.col), (4, 9));
        let expected = (term_area.x + 9, term_area.y + 4);
        terminal.backend_mut().assert_cursor_position(expected);
    }

    /// The hardware cursor must only track the PTY in interactive (input) mode.
    /// In a non-interactive agent view there is no IME input, so the cursor
    /// must not be repositioned — otherwise it leaves a stray blinking cursor
    /// over read-only output.
    #[test]
    fn non_interactive_agent_leaves_hardware_cursor_at_origin() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let args = vec![
            "-c".to_string(),
            "printf '\\033[5;9HX'; sleep 30".to_string(),
        ];
        let client = PtyClient::spawn("/bin/sh", &args, std::path::Path::new("."), 24, 80, 100)
            .expect("spawn pty");
        app.providers.insert(session_id, client);

        // session_surface is Agent so the snapshot is populated, but input is
        // NOT routed to the agent.
        app.session_surface = SessionSurface::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        wait_for_agent_cursor(&mut app, 4, 9);
        app.input_target = InputTarget::None;

        let backend = TestBackend::new(100, 40);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");

        let term_area = app
            .mouse_layout
            .agent_term
            .expect("agent terminal area should be recorded after render");
        // The PTY genuinely has an off-origin cursor that the renderer COULD
        // have placed: term_area is offset and the PTY cursor is at (4, 9), so
        // a regression that wrongly set the hardware cursor would move it away
        // from the origin and fail the assertion below.
        assert!(
            term_area.x > 0 || term_area.y > 0,
            "test setup: agent terminal should be offset from the origin"
        );
        assert!(
            app.snapshot_buf.cursor.is_some(),
            "test setup: the PTY should still expose a cursor to (not) place"
        );

        // Not in input mode → the hardware cursor is never positioned and stays
        // at the backend origin.
        terminal.backend_mut().assert_cursor_position((0u16, 0u16));
    }

    #[test]
    fn timed_out_bare_esc_can_exit_interactive() {
        let bindings = bindings_with_overrides(&[(Action::ExitInteractive, &["esc"])]);
        let mut app = app_with_scrolled_back_pty();
        app.bindings = bindings;
        app.interactive_patterns = app.bindings.interactive_byte_patterns();

        app.process_raw_input_bytes(b"\x1b").unwrap();
        assert_eq!(
            app.input_target,
            InputTarget::Agent,
            "bare ESC is held until the ambiguity timeout resolves it"
        );
        assert_eq!(app.raw_input_buf, b"\x1b");

        app.process_raw_input_bytes(&[]).unwrap();
        assert_eq!(
            app.input_target,
            InputTarget::None,
            "timed-out bare ESC should resolve as ExitInteractive"
        );
        assert!(app.raw_input_buf.is_empty());
    }

    #[test]
    fn pending_esc_before_sgr_mouse_does_not_leave_printable_tail() {
        let bindings = bindings_with_overrides(&[(Action::ExitInteractive, &["esc"])]);
        let mut app = app_with_scrolled_back_pty();
        app.bindings = bindings;
        app.interactive_patterns = app.bindings.interactive_byte_patterns();

        app.process_raw_input_bytes(b"\x1b\x1b[<35;138;12M")
            .unwrap();
        assert_eq!(
            app.input_target,
            InputTarget::None,
            "first ESC should exit interactive mode"
        );
        assert!(
            app.raw_input_buf.is_empty(),
            "SGR mouse report must not leave a [<...M printable remainder"
        );
    }

    #[test]
    fn fragmented_pending_esc_before_sgr_mouse_does_not_leave_printable_tail() {
        let mut app = app_with_scrolled_back_pty();

        app.process_raw_input_bytes(b"\x1b").unwrap();
        assert_eq!(app.raw_input_buf, b"\x1b");

        app.process_raw_input_bytes(b"\x1b[<35;").unwrap();
        assert_eq!(
            app.input_target,
            InputTarget::Agent,
            "default bindings should keep interactive mode active"
        );
        assert_eq!(
            app.raw_input_buf, b"\x1b[<35;",
            "incomplete mouse report should stay pending with its ESC prefix"
        );

        app.process_raw_input_bytes(b"138;12M").unwrap();
        assert!(
            app.raw_input_buf.is_empty(),
            "completed mouse report must not leave a [<...M printable remainder"
        );
    }

    #[test]
    fn app_preserves_alt_key_before_timeout_resolution() {
        let bindings = bindings_with_overrides(&[(Action::ExitInteractive, &["esc"])]);
        let mut app = app_with_scrolled_back_pty();
        app.bindings = bindings;
        app.interactive_patterns = app.bindings.interactive_byte_patterns();

        app.process_raw_input_bytes(b"\x1b").unwrap();
        app.process_raw_input_bytes(b"x").unwrap();

        assert_eq!(
            app.input_target,
            InputTarget::Agent,
            "ESC followed by a key should stay an Alt-key sequence"
        );
        assert!(
            app.raw_input_buf.is_empty(),
            "Alt-key sequence should be complete, not a pending bare ESC"
        );
    }

    #[test]
    fn scrolled_back_suppresses_macro_bar() {
        let mut app = app_with_scrolled_back_pty();

        // Add a macro so OpenMacroBar would normally open the bar.
        app.config.macros.entries.insert(
            "test".to_string(),
            crate::config::MacroEntry {
                text: "hello".to_string(),
                surface: crate::config::MacroSurface::Agent,
            },
        );

        // Find the byte pattern for OpenMacroBar (Ctrl-\, which is 0x1c).
        let matched = app.interactive_patterns.match_sequence(&[0x1c]);
        assert_eq!(
            matched.map(|(a, _)| a),
            Some(Action::OpenMacroBar),
            "Ctrl-\\ should resolve to OpenMacroBar"
        );

        // Feed Ctrl-\ while scrolled back.
        let result = app.process_raw_input_bytes(&[0x1c]).unwrap();
        assert!(!result);
        assert!(
            app.macro_bar.is_none(),
            "macro bar must not open when scrolled back"
        );
    }

    #[test]
    fn not_scrolled_back_forwards_normally() {
        let mut app = app_with_scrolled_back_pty();

        // Reset scrollback to 0 (live bottom).
        app.selected_terminal_surface_client()
            .unwrap()
            .set_scrollback(0);
        assert_eq!(
            app.selected_terminal_surface_client()
                .unwrap()
                .scrollback_offset(),
            0
        );

        // Feed a regular character — should be forwarded without error.
        let result = app.process_raw_input_bytes(b"x").unwrap();
        assert!(!result);
    }

    #[test]
    fn scrolled_back_allows_scroll_page_up() {
        let mut app = app_with_scrolled_back_pty();
        let before = app
            .selected_terminal_surface_client()
            .unwrap()
            .scrollback_offset();

        // Feed PgUp (CSI 5~) — should scroll further back.
        let result = app.process_raw_input_bytes(b"\x1b[5~").unwrap();
        assert!(!result);
        let after = app
            .selected_terminal_surface_client()
            .unwrap()
            .scrollback_offset();
        assert!(
            after >= before,
            "PgUp should increase or maintain scrollback offset"
        );
    }

    #[test]
    fn scrolled_back_allows_scroll_to_bottom() {
        let mut app = app_with_scrolled_back_pty();
        assert!(
            app.selected_terminal_surface_client()
                .unwrap()
                .scrollback_offset()
                > 0
        );

        // Feed 'q' (0x71) — ScrollToBottom should reset scrollback.
        let result = app.process_raw_input_bytes(b"q").unwrap();
        assert!(!result);
        assert_eq!(
            app.selected_terminal_surface_client()
                .unwrap()
                .scrollback_offset(),
            0,
            "'q' should scroll to bottom when scrolled back"
        );
    }

    #[test]
    fn scrolled_back_allows_scroll_to_top_via_home() {
        let mut app = app_with_scrolled_back_pty();
        // Ensure we're scrolled back.
        assert!(
            app.selected_terminal_surface_client()
                .unwrap()
                .scrollback_offset()
                > 0
        );

        // Remember current offset, then reset to a small offset.
        app.selected_terminal_surface_client()
            .unwrap()
            .set_scrollback(2);
        let before = app
            .selected_terminal_surface_client()
            .unwrap()
            .scrollback_offset();

        // Feed Home key escape sequence — ScrollToTop should maximize scrollback.
        let result = app.process_raw_input_bytes(b"\x1b[H").unwrap();
        assert!(!result);
        let after = app
            .selected_terminal_surface_client()
            .unwrap()
            .scrollback_offset();
        assert!(
            after >= before,
            "Home should scroll to top of buffer, got offset {after} (was {before})"
        );
    }

    #[test]
    fn scrolled_back_allows_scroll_to_bottom_via_end() {
        let mut app = app_with_scrolled_back_pty();
        assert!(
            app.selected_terminal_surface_client()
                .unwrap()
                .scrollback_offset()
                > 0
        );

        // Feed End key escape sequence — ScrollToBottom should reset scrollback.
        let result = app.process_raw_input_bytes(b"\x1b[F").unwrap();
        assert!(!result);
        assert_eq!(
            app.selected_terminal_surface_client()
                .unwrap()
                .scrollback_offset(),
            0,
            "End should scroll to bottom when scrolled back"
        );
    }

    // ── Click-outside-fullscreen tests ──────────────────────────────

    /// Build an SGR mouse left-button-down sequence at 1-based (cx, cy).
    fn sgr_mouse_down(cx: u16, cy: u16) -> Vec<u8> {
        format!("\x1b[<0;{cx};{cy}M").into_bytes()
    }

    /// Helper: set up an App with a live PTY in interactive agent mode
    /// and a mouse layout so we can test click-outside-overlay behavior.
    fn app_with_interactive_agent_pty() -> App {
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();

        let args = vec!["-c".to_string(), "sleep 5".to_string()];
        let client = PtyClient::spawn("sh", &args, std::path::Path::new("."), 5, 40, 100)
            .expect("spawn pty");
        app.providers.insert(session_id, client);

        app.input_target = InputTarget::Agent;
        app.session_surface = SessionSurface::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.last_pty_size = (5, 40);
        install_mouse_layout(&mut app);
        app
    }

    #[test]
    fn click_outside_fullscreen_agent_exits_interactive_mode() {
        let mut app = app_with_interactive_agent_pty();
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);

        // Click at (1,1) which is inside the left pane, outside agent_term
        // (agent_term starts at x=21). SGR coords are 1-based.
        let bytes = sgr_mouse_down(2, 2);
        let result = app.process_raw_input_bytes(&bytes).unwrap();
        assert!(!result);
        assert_eq!(
            app.input_target,
            InputTarget::None,
            "clicking outside overlay must exit interactive mode"
        );
        assert_eq!(
            app.fullscreen_overlay,
            FullscreenOverlay::None,
            "clicking outside overlay must dismiss fullscreen"
        );
    }

    #[test]
    fn click_inside_fullscreen_agent_stays_in_interactive_mode() {
        let mut app = app_with_interactive_agent_pty();
        assert_eq!(app.input_target, InputTarget::Agent);

        // Click at (30, 5) which is inside agent_term (x=21..76, y=1..17).
        // SGR coords are 1-based so (31, 6).
        let bytes = sgr_mouse_down(31, 6);
        let result = app.process_raw_input_bytes(&bytes).unwrap();
        assert!(!result);
        assert_eq!(
            app.input_target,
            InputTarget::Agent,
            "clicking inside overlay must stay in interactive mode"
        );
        assert_eq!(
            app.fullscreen_overlay,
            FullscreenOverlay::Agent,
            "clicking inside overlay must keep fullscreen"
        );
    }

    #[test]
    fn click_outside_fullscreen_terminal_exits_interactive_mode() {
        let mut app = app_with_interactive_agent_pty();
        // Switch to terminal interactive mode.
        app.input_target = InputTarget::Terminal;
        app.fullscreen_overlay = FullscreenOverlay::Terminal;
        app.session_surface = SessionSurface::Terminal;

        // Click outside agent_term.
        let bytes = sgr_mouse_down(2, 2);
        let result = app.process_raw_input_bytes(&bytes).unwrap();
        assert!(!result);
        assert_eq!(
            app.input_target,
            InputTarget::None,
            "clicking outside overlay must exit terminal interactive mode"
        );
        assert_eq!(
            app.fullscreen_overlay,
            FullscreenOverlay::None,
            "clicking outside overlay must dismiss terminal fullscreen"
        );
    }

    #[test]
    fn click_outside_fullscreen_terminal_returns_to_left_pane() {
        let mut app = app_with_interactive_agent_pty();
        app.input_target = InputTarget::Terminal;
        app.fullscreen_overlay = FullscreenOverlay::Terminal;
        app.session_surface = SessionSurface::Terminal;
        app.terminal_return_to_list = true;
        app.focus = FocusPane::Center;

        let bytes = sgr_mouse_down(2, 2);
        let result = app.process_raw_input_bytes(&bytes).unwrap();
        assert!(!result);
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        assert_eq!(
            app.focus,
            FocusPane::Left,
            "focus should move to left pane when terminal_return_to_list is set"
        );
    }

    #[test]
    fn scrolled_back_allows_click_outside_exit() {
        // Use the scrolled-back helper (which prints enough lines to create
        // history) and install a mouse layout so click-outside detection works.
        let mut app = app_with_scrolled_back_pty();
        install_mouse_layout(&mut app);
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
        assert!(
            app.selected_terminal_surface_client()
                .unwrap()
                .scrollback_offset()
                > 0,
            "test setup: should be scrolled back"
        );

        // Click outside agent_term while scrolled back — overlay must close.
        let bytes = sgr_mouse_down(2, 2);
        let result = app.process_raw_input_bytes(&bytes).unwrap();
        assert!(!result);
        assert_eq!(
            app.input_target,
            InputTarget::None,
            "clicking outside overlay must exit interactive mode even when scrolled back"
        );
        assert_eq!(
            app.fullscreen_overlay,
            FullscreenOverlay::None,
            "clicking outside overlay must dismiss fullscreen even when scrolled back"
        );
    }

    // ── Loading-phase ExitInteractive tests ─────────────────────────

    #[test]
    fn loading_phase_exit_interactive_single_byte_binding() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;

        // Default ExitInteractive is Ctrl-G (0x07). Put it in loading_input_buf
        // as scan_loading_phase_exit reads from there.
        app.loading_input_buf = vec![0x07];
        assert!(
            app.scan_loading_phase_exit(),
            "scan_loading_phase_exit must detect Ctrl-G"
        );
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
    }

    #[test]
    fn loading_phase_exit_interactive_multi_byte_binding() {
        // Rebind ExitInteractive to Home (ESC [ H) — a multi-byte sequence.
        let bindings = bindings_with_overrides(&[(Action::ExitInteractive, &["home"])]);
        let mut app = test_app(bindings);

        // Verify the binding was registered as an interactive byte pattern.
        let result = app.interactive_patterns.match_sequence(&[0x1b, b'[', b'H']);
        assert_eq!(
            result.map(|(a, _)| a),
            Some(Action::ExitInteractive),
            "ESC [ H must resolve to ExitInteractive in interactive patterns"
        );

        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;

        // Full sequence in one buffer.
        app.loading_input_buf = b"\x1b[H".to_vec();
        assert!(
            app.scan_loading_phase_exit(),
            "scan_loading_phase_exit must detect multi-byte ExitInteractive"
        );
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
    }

    #[test]
    fn loading_phase_exit_interactive_fragmented_across_reads() {
        // Rebind ExitInteractive to Home (ESC [ H) — a multi-byte sequence.
        let bindings = bindings_with_overrides(&[(Action::ExitInteractive, &["home"])]);
        let mut app = test_app(bindings);
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;

        // First "read" delivers only the ESC byte — not a complete sequence.
        app.loading_input_buf = vec![0x1b];
        assert!(
            !app.scan_loading_phase_exit(),
            "incomplete sequence must not trigger exit"
        );
        assert_eq!(
            app.input_target,
            InputTarget::Agent,
            "should still be in interactive mode"
        );

        // Simulate the remainder trimming that poll_and_forward_raw_input
        // does: keep only the incomplete remainder.
        let (_, remainder) = crate::raw_input::split_sequences(&app.loading_input_buf);
        let keep = remainder.len();
        let start = app.loading_input_buf.len() - keep;
        app.loading_input_buf = app.loading_input_buf[start..].to_vec();

        // Second "read" delivers the rest of the sequence.
        app.loading_input_buf.extend_from_slice(b"[H");
        assert!(
            app.scan_loading_phase_exit(),
            "completed sequence across reads must trigger exit"
        );
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
    }

    #[test]
    fn loading_phase_ignores_non_exit_input() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;

        // Regular ASCII input should not trigger exit.
        app.loading_input_buf = b"hello".to_vec();
        assert!(
            !app.scan_loading_phase_exit(),
            "regular input must not trigger exit"
        );
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn append_capped_keeps_trailing_bytes_on_large_chunk() {
        let mut buf = b"prefix".to_vec();
        let big: Vec<u8> = (0..200u8).collect();
        super::append_capped(&mut buf, &big, super::LOADING_INPUT_BUF_MAX);
        assert_eq!(
            buf.len(),
            super::LOADING_INPUT_BUF_MAX,
            "buffer must be capped to the configured maximum"
        );
        // When the incoming chunk alone exceeds the cap, prior prefix is
        // discarded and only the trailing window of the new chunk remains.
        let expected_tail: Vec<u8> = (200 - super::LOADING_INPUT_BUF_MAX as u8..200u8).collect();
        assert_eq!(buf, expected_tail);
    }

    #[test]
    fn append_capped_drops_oldest_bytes_on_incremental_overflow() {
        // Fill exactly to cap with distinct leading bytes.
        let mut buf: Vec<u8> = (0..super::LOADING_INPUT_BUF_MAX as u8).collect();
        // Append 5 more bytes — should drop the first 5.
        super::append_capped(
            &mut buf,
            &[0xA, 0xB, 0xC, 0xD, 0xE],
            super::LOADING_INPUT_BUF_MAX,
        );
        assert_eq!(buf.len(), super::LOADING_INPUT_BUF_MAX);
        assert_eq!(
            &buf[buf.len() - 5..],
            &[0xA, 0xB, 0xC, 0xD, 0xE],
            "most recent bytes must be preserved at the tail"
        );
        assert_eq!(
            buf[0], 5,
            "first 5 original bytes (0..=4) must have been dropped"
        );
    }

    #[test]
    fn loading_phase_cap_still_matches_exit_interactive_at_tail() {
        // Ensure that after a large paste, a trailing single-byte
        // ExitInteractive (Ctrl-G) still triggers exit.
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;

        // Fill the buffer with filler past the cap, then put Ctrl-G at the
        // tail. append_capped would have done the same; we prepare the
        // post-cap state directly here.
        let mut filler: Vec<u8> = vec![b'x'; super::LOADING_INPUT_BUF_MAX - 1];
        filler.push(0x07);
        app.loading_input_buf = filler;
        assert!(
            app.scan_loading_phase_exit(),
            "Ctrl-G at the tail of a capped buffer must still trigger exit"
        );
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
    }

    // ── Loading-phase mouse click-outside tests ─────────────────────

    #[test]
    fn loading_phase_click_outside_overlay_exits_interactive_mode() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;
        install_mouse_layout(&mut app);

        // Click at (1,1) which is inside the left pane, outside agent_term
        // (agent_term starts at x=21). SGR coords are 1-based, so (2,2).
        app.loading_input_buf = sgr_mouse_down(2, 2);
        assert!(
            app.scan_loading_phase_exit(),
            "left-click outside overlay must trigger exit during loading"
        );
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
    }

    #[test]
    fn loading_phase_click_inside_overlay_stays_in_interactive_mode() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;
        install_mouse_layout(&mut app);

        // Click at (30, 5) which is inside agent_term (x=21..76, y=1..17).
        // SGR coords are 1-based so (31, 6).
        app.loading_input_buf = sgr_mouse_down(31, 6);
        assert!(
            !app.scan_loading_phase_exit(),
            "left-click inside overlay must not trigger exit during loading"
        );
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn loading_phase_right_click_outside_overlay_does_not_exit() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;
        install_mouse_layout(&mut app);

        // Right-click outside the overlay. SGR button 2 = right button.
        // Format: ESC [ < 2 ; cx ; cy M
        app.loading_input_buf = b"\x1b[<2;2;2M".to_vec();
        assert!(
            !app.scan_loading_phase_exit(),
            "right-click must not trigger exit during loading"
        );
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn loading_phase_scroll_outside_overlay_does_not_exit() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;
        install_mouse_layout(&mut app);

        // Scroll-up outside the overlay. SGR button 64 = scroll up.
        // Format: ESC [ < 64 ; cx ; cy M
        app.loading_input_buf = b"\x1b[<64;2;2M".to_vec();
        assert!(
            !app.scan_loading_phase_exit(),
            "scroll events must not trigger exit during loading"
        );
        assert_eq!(app.input_target, InputTarget::Agent);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn loading_phase_click_outside_without_mouse_layout_exits() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;
        app.session_surface = SessionSurface::Agent;
        // No mouse layout installed — agent_term is None.
        // Any left-click should be treated as outside.
        app.loading_input_buf = sgr_mouse_down(50, 10);
        assert!(
            app.scan_loading_phase_exit(),
            "left-click must exit when agent_term rect is not set"
        );
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
    }

    #[test]
    fn loading_phase_click_outside_terminal_overlay_exits() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Terminal;
        app.fullscreen_overlay = FullscreenOverlay::Terminal;
        app.session_surface = SessionSurface::Terminal;
        app.terminal_return_to_list = true;
        app.focus = FocusPane::Center;
        install_mouse_layout(&mut app);

        // Click outside agent_term (which also covers terminal overlays).
        app.loading_input_buf = sgr_mouse_down(2, 2);
        assert!(
            app.scan_loading_phase_exit(),
            "left-click outside must exit terminal interactive mode during loading"
        );
        assert_eq!(app.input_target, InputTarget::None);
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::None);
        assert_eq!(
            app.focus,
            FocusPane::Left,
            "focus should move to left pane when terminal_return_to_list is set"
        );
    }

    // ---------------------------------------------------------------
    // Double-click detection: item-scoped
    // ---------------------------------------------------------------

    #[test]
    fn double_click_same_pane_same_item() {
        let bindings = default_bindings();
        let mut app = test_app(bindings);

        let first = app.register_mouse_click(MouseClickTarget::LeftPane, Some(2));
        assert!(!first, "first click must not be a double-click");

        let second = app.register_mouse_click(MouseClickTarget::LeftPane, Some(2));
        assert!(
            second,
            "second click on the same item within threshold must be a double-click"
        );
    }

    #[test]
    fn no_double_click_same_pane_different_item() {
        let bindings = default_bindings();
        let mut app = test_app(bindings);

        let first = app.register_mouse_click(MouseClickTarget::LeftPane, Some(2));
        assert!(!first);

        let second = app.register_mouse_click(MouseClickTarget::LeftPane, Some(5));
        assert!(
            !second,
            "clicking a different item in the same pane must NOT be a double-click"
        );
    }

    #[test]
    fn no_double_click_same_item_after_timeout() {
        let bindings = default_bindings();
        let mut app = test_app(bindings);

        let first = app.register_mouse_click(MouseClickTarget::LeftPane, Some(2));
        assert!(!first);

        // Simulate timeout by back-dating the stored click.
        if let Some(ref mut last) = app.last_mouse_click {
            last.at -= DOUBLE_CLICK_THRESHOLD + std::time::Duration::from_millis(1);
        }

        let second = app.register_mouse_click(MouseClickTarget::LeftPane, Some(2));
        assert!(
            !second,
            "clicking the same item after the threshold must NOT be a double-click"
        );
    }

    #[test]
    fn no_double_click_different_pane_same_index() {
        let bindings = default_bindings();
        let mut app = test_app(bindings);

        let first = app.register_mouse_click(MouseClickTarget::LeftPane, Some(0));
        assert!(!first);

        let second = app.register_mouse_click(MouseClickTarget::UnstagedPane, Some(0));
        assert!(
            !second,
            "clicking the same index in a different pane must NOT be a double-click"
        );
    }

    #[test]
    fn double_click_resets_after_trigger() {
        let bindings = default_bindings();
        let mut app = test_app(bindings);

        // First pair: triggers double-click.
        app.register_mouse_click(MouseClickTarget::LeftPane, Some(1));
        let triggered = app.register_mouse_click(MouseClickTarget::LeftPane, Some(1));
        assert!(triggered);

        // The state should be cleared — the next click starts a fresh sequence.
        let after = app.register_mouse_click(MouseClickTarget::LeftPane, Some(1));
        assert!(
            !after,
            "after a double-click triggers, the next click must start a new sequence"
        );
    }

    #[test]
    fn double_click_center_pane_no_item_index() {
        let bindings = default_bindings();
        let mut app = test_app(bindings);

        let first = app.register_mouse_click(MouseClickTarget::CenterPane, None);
        assert!(!first);

        let second = app.register_mouse_click(MouseClickTarget::CenterPane, None);
        assert!(
            second,
            "double-click on center pane (no item index) must work"
        );
    }

    #[test]
    fn ctrl_d_in_terminal_list_shows_confirm_dialog() {
        let mut app = test_app(default_bindings());
        app.show_companion_terminal()
            .expect("launch companion terminal");

        // Close overlay so we're back in the normal left pane.
        app.fullscreen_overlay = FullscreenOverlay::None;
        app.input_target = InputTarget::None;
        app.session_surface = SessionSurface::Agent;
        app.left_section = LeftSection::Terminals;
        app.focus = FocusPane::Left;
        app.selected_terminal_index = 0;

        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
        app.handle_key(ctrl_d).unwrap();

        assert!(
            matches!(app.prompt, PromptState::ConfirmDeleteTerminal { .. }),
            "Ctrl+D in terminal list should show confirm delete terminal dialog"
        );
    }

    #[test]
    fn confirm_delete_terminal_removes_terminal() {
        let mut app = test_app(default_bindings());
        app.show_companion_terminal()
            .expect("launch companion terminal");

        assert_eq!(app.companion_terminals.len(), 1);
        let terminal_id = app.terminal_items()[0].0.clone();

        app.prompt = PromptState::ConfirmDeleteTerminal {
            terminal_id: terminal_id.clone(),
            terminal_label: "test".to_string(),
            confirm_selected: true,
        };

        app.resolve_confirm_delete_terminal(true);

        assert!(
            app.companion_terminals.is_empty(),
            "terminal should be removed after confirm"
        );
        assert!(matches!(app.prompt, PromptState::None));
    }

    #[test]
    fn cancel_delete_terminal_keeps_terminal() {
        let mut app = test_app(default_bindings());
        app.show_companion_terminal()
            .expect("launch companion terminal");

        assert_eq!(app.companion_terminals.len(), 1);
        let terminal_id = app.terminal_items()[0].0.clone();

        app.prompt = PromptState::ConfirmDeleteTerminal {
            terminal_id,
            terminal_label: "test".to_string(),
            confirm_selected: false,
        };

        app.resolve_confirm_delete_terminal(false);

        assert_eq!(
            app.companion_terminals.len(),
            1,
            "terminal should remain after cancel"
        );
        assert!(matches!(app.prompt, PromptState::None));
    }

    #[test]
    fn delete_last_terminal_switches_to_projects() {
        let mut app = test_app(default_bindings());
        app.show_companion_terminal()
            .expect("launch companion terminal");

        app.fullscreen_overlay = FullscreenOverlay::None;
        app.input_target = InputTarget::None;
        app.session_surface = SessionSurface::Agent;
        app.left_section = LeftSection::Terminals;

        let terminal_id = app.terminal_items()[0].0.clone();
        app.do_delete_terminal(&terminal_id);

        assert!(app.companion_terminals.is_empty());
        assert_eq!(
            app.left_section,
            LeftSection::Projects,
            "should switch back to projects when last terminal deleted"
        );
    }

    // ---------------------------------------------------------------
    // Bracket paste and input batching tests
    // ---------------------------------------------------------------

    #[test]
    fn bracket_paste_skips_intercept_matching() {
        // Ctrl-G (0x07) normally triggers ExitInteractive. Inside a
        // bracket paste it should be forwarded, not intercepted.
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;

        // Spawn a real PTY so write_bytes has somewhere to go.
        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn(
                "sh",
                &["-c".to_string(), "cat; sleep 0.5".to_string()],
                std::path::Path::new("."),
                10,
                10,
                100,
            )
            .expect("spawn pty"),
        );
        // Wait for the shell to produce output so has_output() is true.
        std::thread::sleep(std::time::Duration::from_millis(100));

        // Build input: ESC[200~ + Ctrl-G + ESC[201~
        let mut input = Vec::new();
        input.extend_from_slice(crate::raw_input::BRACKET_PASTE_START);
        input.push(0x07); // Ctrl-G — would be ExitInteractive outside paste
        input.extend_from_slice(crate::raw_input::BRACKET_PASTE_END);

        let result = app.process_raw_input_bytes(&input).unwrap();
        assert!(!result, "should not request exit");

        // The key assertion: we should still be in interactive mode.
        // If the Ctrl-G was intercepted, input_target would be None.
        assert_eq!(
            app.input_target,
            InputTarget::Agent,
            "Ctrl-G inside bracket paste must not exit interactive mode"
        );
        assert_eq!(app.fullscreen_overlay, FullscreenOverlay::Agent);
    }

    #[test]
    fn bracket_paste_state_tracks_across_calls() {
        // If the paste start marker arrives in one read and the end
        // marker in the next, in_bracket_paste must persist.
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;

        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn(
                "sh",
                &["-c".to_string(), "cat; sleep 0.5".to_string()],
                std::path::Path::new("."),
                10,
                10,
                100,
            )
            .expect("spawn pty"),
        );
        std::thread::sleep(std::time::Duration::from_millis(100));

        // First chunk: start marker + some text.
        let mut chunk1 = Vec::new();
        chunk1.extend_from_slice(crate::raw_input::BRACKET_PASTE_START);
        chunk1.extend_from_slice(b"abc");
        app.process_raw_input_bytes(&chunk1).unwrap();
        assert!(
            app.in_bracket_paste,
            "should be in bracket paste after start marker"
        );

        // Second chunk: more text + end marker.
        let mut chunk2 = Vec::new();
        chunk2.extend_from_slice(b"def");
        chunk2.extend_from_slice(crate::raw_input::BRACKET_PASTE_END);
        app.process_raw_input_bytes(&chunk2).unwrap();
        assert!(
            !app.in_bracket_paste,
            "should exit bracket paste after end marker"
        );
    }

    #[test]
    fn bracket_paste_state_reset_on_exit_interactive() {
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;
        app.fullscreen_overlay = FullscreenOverlay::Agent;

        app.providers.insert(
            "session-1".to_string(),
            PtyClient::spawn(
                "sh",
                &["-c".to_string(), "cat; sleep 0.5".to_string()],
                std::path::Path::new("."),
                10,
                10,
                100,
            )
            .expect("spawn pty"),
        );
        std::thread::sleep(std::time::Duration::from_millis(100));

        // Enter bracket paste state.
        app.in_bracket_paste = true;

        // Now send ExitInteractive (Ctrl-G) outside of paste context.
        // Reset in_bracket_paste first to simulate that the end marker
        // was never received (e.g. malformed paste), but we exit.
        app.in_bracket_paste = false;
        app.process_raw_input_bytes(b"\x07").unwrap();

        assert!(!app.in_bracket_paste, "should be reset after exit");
        assert_eq!(app.input_target, InputTarget::None);
    }

    #[test]
    fn forward_batching_sends_all_bytes() {
        // Verify that multiple plain characters are forwarded to the PTY
        // (they should be batched into a single write, but we can at
        // least verify the bytes arrive by reading them back).
        let mut app = test_app(default_bindings());
        app.input_target = InputTarget::Agent;

        let client = PtyClient::spawn(
            "sh",
            &["-c".to_string(), "cat; sleep 0.5".to_string()],
            std::path::Path::new("."),
            10,
            10,
            100,
        )
        .expect("spawn pty");
        app.providers.insert("session-1".to_string(), client);
        std::thread::sleep(std::time::Duration::from_millis(100));

        // Feed 100 'x' characters — they should all be forwarded.
        let input = vec![b'x'; 100];
        let result = app.process_raw_input_bytes(&input).unwrap();
        assert!(!result);
        // We should still be in interactive mode (nothing intercepted).
        assert_eq!(app.input_target, InputTarget::Agent);
        // The raw_input_buf should be empty (no remainder).
        assert!(app.raw_input_buf.is_empty());
    }

    fn app_with_two_macros() -> App {
        let mut app = test_app(default_bindings());
        app.config.macros.entries.insert(
            "greet".to_string(),
            crate::config::MacroEntry {
                text: "hello".to_string(),
                surface: crate::config::MacroSurface::Agent,
            },
        );
        app.config.macros.entries.insert(
            "farewell".to_string(),
            crate::config::MacroEntry {
                text: "goodbye".to_string(),
                surface: crate::config::MacroSurface::Agent,
            },
        );
        app.open_edit_macros();
        app
    }

    fn pending_delete_state(app: &App) -> Option<(&str, bool)> {
        if let PromptState::EditMacros {
            pending_delete: Some(pending),
            ..
        } = &app.prompt
        {
            Some((pending.name.as_str(), pending.confirm_selected))
        } else {
            None
        }
    }

    #[test]
    fn macro_delete_key_stages_confirmation_without_mutating_config() {
        let mut app = app_with_two_macros();

        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
            .expect("handle d");

        assert_eq!(pending_delete_state(&app), Some(("greet", false)));
        assert_eq!(
            app.config.macros.entries.len(),
            2,
            "macro must not be removed from config until confirmed"
        );
        assert!(app.config.macros.entries.contains_key("greet"));
    }

    #[test]
    fn macro_delete_esc_clears_pending_without_deleting() {
        let mut app = app_with_two_macros();
        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
            .expect("handle d");
        assert!(pending_delete_state(&app).is_some());

        app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
            .expect("handle esc");

        assert!(pending_delete_state(&app).is_none());
        assert!(
            matches!(app.prompt, PromptState::EditMacros { .. }),
            "macro editor should remain open after dismissing the confirm dialog"
        );
        assert_eq!(app.config.macros.entries.len(), 2);
    }

    #[test]
    fn macro_delete_tab_toggles_confirm_button_focus() {
        let mut app = app_with_two_macros();
        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
            .expect("handle d");
        assert_eq!(pending_delete_state(&app), Some(("greet", false)));

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .expect("handle tab");
        assert_eq!(pending_delete_state(&app), Some(("greet", true)));

        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .expect("handle tab");
        assert_eq!(pending_delete_state(&app), Some(("greet", false)));
    }

    #[test]
    fn macro_delete_enter_on_cancel_is_noop() {
        let mut app = app_with_two_macros();
        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
            .expect("handle d");

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .expect("handle enter");

        assert!(pending_delete_state(&app).is_none());
        assert_eq!(app.config.macros.entries.len(), 2);
        assert!(app.config.macros.entries.contains_key("greet"));
    }

    #[test]
    fn macro_delete_enter_on_delete_removes_and_persists() {
        let mut app = app_with_two_macros();
        let config_path = app.paths.config_path.clone();
        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
            .expect("handle d");
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .expect("handle tab");

        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .expect("handle enter");

        assert!(pending_delete_state(&app).is_none());
        assert!(
            !app.config.macros.entries.contains_key("greet"),
            "macro should be removed from in-memory config"
        );
        assert_eq!(app.config.macros.entries.len(), 1);

        if let PromptState::EditMacros { entries, .. } = &app.prompt {
            assert!(
                entries.iter().all(|(n, _, _)| n != "greet"),
                "EditMacros snapshot should reflect the deletion"
            );
        } else {
            panic!("expected EditMacros prompt after resolving delete");
        }

        assert!(
            config_path.exists(),
            "config file should have been written to disk"
        );
        let disk = std::fs::read_to_string(&config_path).expect("read config");
        assert!(
            !disk.contains("greet"),
            "deleted macro should not appear in saved config"
        );
    }

    #[test]
    fn macro_delete_surfaces_save_failure_on_status_line() {
        let mut app = app_with_two_macros();
        // Point config_path at a directory that doesn't exist so save_config fails.
        app.paths.config_path = app
            .paths
            .root
            .join("nope")
            .join("missing")
            .join("config.toml");

        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
            .expect("handle d");
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .expect("handle tab");
        app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .expect("handle enter");

        assert_eq!(
            app.status.tone(),
            crate::statusline::StatusTone::Error,
            "save failure should produce an error-toned status message"
        );
        let msg = app.status.message();
        assert!(
            msg.contains("greet"),
            "status message should name the affected macro, got: {msg}"
        );
        assert!(
            msg.to_lowercase().contains("config"),
            "status message should mention config, got: {msg}"
        );
    }

    #[test]
    fn macro_delete_space_activates_focused_button() {
        let mut app = app_with_two_macros();
        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
            .expect("handle d");

        // Space on Cancel (default focus) should cancel without deleting.
        app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
            .expect("handle space");
        assert!(pending_delete_state(&app).is_none());
        assert_eq!(app.config.macros.entries.len(), 2);

        // Now re-open and focus Delete, then Space should remove.
        app.handle_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE))
            .expect("handle d");
        app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))
            .expect("handle tab");
        app.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE))
            .expect("handle space");

        assert!(pending_delete_state(&app).is_none());
        assert!(!app.config.macros.entries.contains_key("greet"));
    }

    #[test]
    fn macro_bar_enter_sends_text_without_bracket_paste_wrapping() {
        // Spawn a PTY running `cat -v` in raw mode. `-v` renders control
        // bytes as printable caret notation (`^[` for ESC), and raw mode
        // disables line discipline so bytes are visible immediately.
        // If the macro handler wraps the text in bracket-paste markers
        // (ESC[200~ ... ESC[201~), the terminal snapshot will contain
        // literal `^[[200` and `^[[201` strings. If the text is sent as-is,
        // only the macro text will appear.
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let client = PtyClient::spawn(
            "sh",
            &["-c".to_string(), "stty raw -echo; exec cat -v".to_string()],
            std::path::Path::new("."),
            5,
            80,
            100,
        )
        .expect("spawn pty");
        app.providers.insert(session_id, client);
        app.input_target = InputTarget::Agent;
        app.session_surface = crate::model::SessionSurface::Agent;

        // Give the shell a moment to enter raw mode and exec cat.
        std::thread::sleep(std::time::Duration::from_millis(250));

        app.config.macros.entries.insert(
            "greet".to_string(),
            crate::config::MacroEntry {
                text: "hello-macro".to_string(),
                surface: crate::config::MacroSurface::Agent,
            },
        );

        app.macro_bar = Some(MacroBarState {
            input: TextInput::new(),
            selected: 0,
            previous_input_target: InputTarget::Agent,
        });

        app.handle_macro_bar_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .expect("handle enter");

        assert!(app.macro_bar.is_none(), "macro bar closes after send");
        assert_eq!(app.status.message(), "Sent macro \"greet\".");

        // Wait for cat to echo the bytes back into the embedded terminal.
        std::thread::sleep(std::time::Duration::from_millis(300));

        let provider = app.providers.values().next().expect("provider");
        let snapshot = provider.snapshot();
        let rendered: String = snapshot
            .cells
            .iter()
            .map(|cell| cell.symbol.as_str())
            .collect();
        assert!(
            rendered.contains("hello-macro"),
            "terminal snapshot should contain raw macro text; got: {rendered:?}"
        );
        assert!(
            !rendered.contains("[200") && !rendered.contains("[201"),
            "macro text must not be wrapped in bracket-paste markers; got: {rendered:?}"
        );
    }

    #[test]
    fn macro_payload_translates_newlines_to_alt_enter() {
        use crate::app::input::macro_payload_bytes;

        // Plain text with no newlines passes through byte-for-byte.
        assert_eq!(macro_payload_bytes("hello").as_slice(), b"hello");

        // Bare LF (\n) becomes ESC + CR.
        assert_eq!(macro_payload_bytes("a\nb").as_slice(), b"a\x1b\rb");

        // Bare CR (\r) becomes ESC + CR.
        assert_eq!(macro_payload_bytes("a\rb").as_slice(), b"a\x1b\rb");

        // CRLF collapses to a single ESC + CR (not two).
        assert_eq!(macro_payload_bytes("a\r\nb").as_slice(), b"a\x1b\rb");

        // Multiple newlines each become their own ESC + CR.
        assert_eq!(
            macro_payload_bytes("a\nb\nc").as_slice(),
            b"a\x1b\rb\x1b\rc"
        );

        // Trailing and leading newlines are also translated.
        assert_eq!(macro_payload_bytes("\na\n").as_slice(), b"\x1b\ra\x1b\r");

        // Empty input yields empty payload.
        assert!(macro_payload_bytes("").is_empty());
    }

    #[test]
    fn macro_bar_enter_translates_newlines_to_alt_enter() {
        // Spawn a PTY running `cat -v` in raw mode. Control bytes render
        // as caret notation: ESC → `^[`, CR → `^M`. If newlines are sent
        // raw, `cat` would receive a literal LF which (even in raw mode)
        // would not become `^[^M` in the snapshot. If the translation
        // works, the `^[` and `^M` markers will appear between the two
        // halves of the macro text.
        let mut app = test_app(default_bindings());
        let session_id = app.sessions[0].id.clone();
        let client = PtyClient::spawn(
            "sh",
            &["-c".to_string(), "stty raw -echo; exec cat -v".to_string()],
            std::path::Path::new("."),
            5,
            80,
            100,
        )
        .expect("spawn pty");
        app.providers.insert(session_id, client);
        app.input_target = InputTarget::Agent;
        app.session_surface = crate::model::SessionSurface::Agent;

        std::thread::sleep(std::time::Duration::from_millis(250));

        app.config.macros.entries.insert(
            "multi".to_string(),
            crate::config::MacroEntry {
                text: "first\nsecond".to_string(),
                surface: crate::config::MacroSurface::Agent,
            },
        );

        app.macro_bar = Some(MacroBarState {
            input: TextInput::new(),
            selected: 0,
            previous_input_target: InputTarget::Agent,
        });

        app.handle_macro_bar_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
            .expect("handle enter");

        assert_eq!(app.status.message(), "Sent macro \"multi\".");

        std::thread::sleep(std::time::Duration::from_millis(300));

        let provider = app.providers.values().next().expect("provider");
        let snapshot = provider.snapshot();
        let rendered: String = snapshot
            .cells
            .iter()
            .map(|cell| cell.symbol.as_str())
            .collect();

        assert!(
            rendered.contains("first") && rendered.contains("second"),
            "both halves of the macro text should be visible; got: {rendered:?}"
        );
        assert!(
            rendered.contains("^[") && rendered.contains("^M"),
            "newline should have been translated to ESC+CR (`^[^M`); got: {rendered:?}"
        );
    }

    #[test]
    fn change_default_provider_updates_config_and_refreshes_projects() {
        let mut app = test_app(default_bindings());
        app.config.defaults.provider = "codex".to_string();

        // project-2 pins itself to "gemini" and must not be touched.
        let pinned = Project {
            id: "project-2".to_string(),
            name: "pinned".to_string(),
            path: app.paths.root.join("pinned").display().to_string(),
            explicit_default_provider: Some(ProviderKind::from_str("gemini")),
            default_provider: ProviderKind::from_str("gemini"),
            leading_branch: Some("main".to_string()),
            auto_reopen_agents: None,
            startup_command: None,
            env: Default::default(),
            current_branch: "main".to_string(),
            branch_status: ProjectBranchStatus::Unknown,
            path_missing: false,
        };
        app.session_store
            .upsert_project(&ProjectConfig {
                id: pinned.id.clone(),
                path: pinned.path.clone(),
                name: Some(pinned.name.clone()),
                default_provider: Some("gemini".to_string()),
                leading_branch: Some("main".to_string()),
                auto_reopen_agents: None,
                startup_command: None,
                env: pinned.env.clone(),
            })
            .expect("seed pinned project");
        app.projects.push(pinned);

        let original_session_provider = app.sessions[0].provider.clone();

        app.open_change_default_provider_prompt()
            .expect("open default provider picker");
        match &mut app.prompt {
            PromptState::ChangeDefaultProvider(prompt) => {
                prompt.selected = prompt
                    .options
                    .iter()
                    .position(|option| option.provider.as_str() == "claude")
                    .expect("claude option present");
            }
            other => panic!("expected change-default-provider prompt, got {other:?}"),
        }

        app.apply_change_default_provider()
            .expect("apply default provider");

        // Config was updated in memory and on disk.
        assert_eq!(app.config.defaults.provider, "claude");
        let persisted = crate::config::ensure_config(&app.paths).expect("reload config");
        assert_eq!(persisted.defaults.provider, "claude");

        // Inherited project picks up the new default.
        let inherited = app
            .projects
            .iter()
            .find(|project| project.id == "project-1")
            .expect("inherited project present");
        assert_eq!(inherited.default_provider.as_str(), "claude");

        // Pinned project keeps its explicit override.
        let pinned = app
            .projects
            .iter()
            .find(|project| project.id == "project-2")
            .expect("pinned project present");
        assert_eq!(pinned.default_provider.as_str(), "gemini");

        // Existing sessions are untouched — provider is frozen at creation.
        assert_eq!(app.sessions[0].provider, original_session_provider);

        assert!(matches!(app.prompt, PromptState::None));
        assert!(
            app.status
                .text()
                .contains("Global default provider changed to claude")
        );
    }

    #[test]
    fn change_default_provider_rejects_current_selection_without_mutating_config() {
        let mut app = test_app(default_bindings());
        app.config.defaults.provider = "codex".to_string();

        app.open_change_default_provider_prompt()
            .expect("open default provider picker");
        match &mut app.prompt {
            PromptState::ChangeDefaultProvider(prompt) => {
                prompt.selected = prompt
                    .options
                    .iter()
                    .position(|option| option.is_current)
                    .expect("current option present");
            }
            other => panic!("expected change-default-provider prompt, got {other:?}"),
        }

        app.apply_change_default_provider()
            .expect("apply no-op selection");

        assert_eq!(app.config.defaults.provider, "codex");
        assert!(matches!(app.prompt, PromptState::None));
        assert!(
            app.status
                .text()
                .contains("is already the global default provider")
        );
    }

    #[test]
    fn toggle_project_auto_reopen_persists_opt_out() {
        let mut app = test_app(default_bindings());
        app.selected_left = 0;

        app.execute_command("toggle-project-auto-reopen-agents".to_string())
            .expect("toggle project auto-reopen");
        drain_until(&mut app, |app| {
            app.status
                .text()
                .contains("Startup auto-reopen disabled for project")
        });

        assert_eq!(app.projects[0].auto_reopen_agents, Some(false));
        let persisted = app.session_store.load_projects().expect("load projects");
        assert_eq!(persisted[0].auto_reopen_agents, Some(false));
        assert!(
            app.status
                .text()
                .contains("Startup auto-reopen disabled for project")
        );
    }

    #[test]
    fn toggle_agent_auto_reopen_persists_session_opt_out() {
        let mut app = test_app(default_bindings());

        app.execute_command("toggle-agent-auto-reopen".to_string())
            .expect("toggle agent auto-reopen");

        assert!(!app.sessions[0].auto_reopen_enabled);
        let loaded = app.session_store.load_sessions().expect("load sessions");
        assert_eq!(loaded.len(), 1);
        assert!(!loaded[0].auto_reopen_enabled);
        assert!(
            app.status
                .text()
                .contains("Startup auto-reopen disabled for agent")
        );
    }

    #[test]
    fn change_project_default_provider_updates_selected_project_only() {
        let mut app = test_app(default_bindings());
        app.config.defaults.provider = "codex".to_string();

        let pinned = Project {
            id: "project-2".to_string(),
            name: "pinned".to_string(),
            path: app.paths.root.join("pinned").display().to_string(),
            explicit_default_provider: Some(ProviderKind::from_str("gemini")),
            default_provider: ProviderKind::from_str("gemini"),
            leading_branch: Some("main".to_string()),
            auto_reopen_agents: None,
            startup_command: None,
            env: Default::default(),
            current_branch: "main".to_string(),
            branch_status: ProjectBranchStatus::Unknown,
            path_missing: false,
        };
        app.session_store
            .upsert_project(&ProjectConfig {
                id: pinned.id.clone(),
                path: pinned.path.clone(),
                name: Some(pinned.name.clone()),
                default_provider: Some("gemini".to_string()),
                leading_branch: Some("main".to_string()),
                auto_reopen_agents: None,
                startup_command: None,
                env: pinned.env.clone(),
            })
            .expect("seed pinned project");
        app.projects.push(pinned);

        app.open_change_project_default_provider_prompt()
            .expect("open project provider picker");
        match &mut app.prompt {
            PromptState::ChangeProjectDefaultProvider(prompt) => {
                prompt.selected = prompt
                    .options
                    .iter()
                    .position(|option| {
                        option
                            .provider
                            .as_ref()
                            .map(|provider| provider.as_str() == "claude")
                            .unwrap_or(false)
                    })
                    .expect("claude option present");
            }
            other => panic!("expected change-project-default-provider prompt, got {other:?}"),
        }

        app.apply_change_project_default_provider()
            .expect("apply project provider");
        drain_until(&mut app, |app| {
            app.status.text().contains("changed to claude")
        });

        assert_eq!(app.config.defaults.provider, "codex");
        let persisted = app.session_store.load_projects().expect("load projects");
        let inherited_cfg = persisted
            .iter()
            .find(|project| project.id == "project-1")
            .expect("project-1 config");
        assert_eq!(inherited_cfg.default_provider.as_deref(), Some("claude"));

        let pinned_cfg = persisted
            .iter()
            .find(|project| project.id == "project-2")
            .expect("project-2 config");
        assert_eq!(pinned_cfg.default_provider.as_deref(), Some("gemini"));

        let project = app
            .projects
            .iter()
            .find(|project| project.id == "project-1")
            .expect("updated project");
        assert_eq!(project.default_provider.as_str(), "claude");

        assert!(
            app.status
                .text()
                .contains("Project provider for \"demo\" changed to claude")
        );
    }

    #[test]
    fn change_project_default_provider_can_revert_to_global_default() {
        let mut app = test_app(default_bindings());
        app.config.defaults.provider = "codex".to_string();
        app.session_store
            .update_project_default_provider(&app.projects[0].id, Some("gemini"))
            .expect("seed project override");
        app.projects[0].explicit_default_provider = Some(ProviderKind::from_str("gemini"));
        app.projects[0].default_provider = ProviderKind::from_str("gemini");

        app.open_change_project_default_provider_prompt()
            .expect("open project provider picker");
        match &mut app.prompt {
            PromptState::ChangeProjectDefaultProvider(prompt) => {
                prompt.selected = prompt
                    .options
                    .iter()
                    .position(|option| option.provider.is_none())
                    .expect("inherit option present");
            }
            other => panic!("expected change-project-default-provider prompt, got {other:?}"),
        }

        app.apply_change_project_default_provider()
            .expect("apply inherit global");
        drain_until(&mut app, |app| {
            app.status
                .text()
                .contains("now inherits the global default provider")
        });

        let persisted = app.session_store.load_projects().expect("load projects");
        let config = persisted
            .iter()
            .find(|project| project.id == "project-1")
            .expect("project-1 config");
        assert_eq!(config.default_provider, None);
        assert_eq!(app.projects[0].default_provider.as_str(), "codex");
        assert!(
            app.status
                .text()
                .contains("\"demo\" now inherits the global default provider (codex)")
        );
    }

    #[test]
    fn header_shows_development_version_for_local_builds() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        let backend = TestBackend::new(120, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");
        let rendered: String = terminal
            .backend()
            .buffer()
            .content
            .iter()
            .map(|cell| cell.symbol())
            .collect();

        assert!(
            rendered.contains("dux development"),
            "expected local build version label, got: {rendered}"
        );
    }

    #[test]
    fn empty_projects_separator_centers_label_and_empty_projects_use_quiet_style() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;
        use ratatui::style::Modifier;

        let mut app = test_app(default_bindings());
        append_empty_projects(&mut app, 4);
        app.config.ui.empty_project_separator_min_projects = 5;
        app.rebuild_left_items();

        let backend = TestBackend::new(160, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");

        let buffer = terminal.backend().buffer();
        let spacer_row: String = (1u16..31u16).map(|x| buffer[(x, 4)].symbol()).collect();
        let separator_row: String = (1u16..31u16).map(|x| buffer[(x, 5)].symbol()).collect();

        assert_eq!(spacer_row, "                              ");
        assert_eq!(separator_row, "── Projects with no agents ───");
        assert_eq!(buffer[(1, 5)].fg, app.theme.header_separator_fg);
        assert_eq!(buffer[(5, 5)].fg, app.theme.provider_label_fg);
        assert!(
            buffer[(3, 2)].modifier.contains(Modifier::BOLD),
            "project above the empty-projects separator should stay bold"
        );
        assert!(
            !buffer[(3, 6)].modifier.contains(Modifier::BOLD),
            "project below the empty-projects separator should render at normal weight"
        );
        assert_eq!(buffer[(1, 6)].symbol(), "⧉");
        assert_eq!(buffer[(1, 6)].fg, app.theme.provider_label_fg);
    }

    #[test]
    fn header_shows_project_and_global_provider_when_project_is_overridden() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        app.config.defaults.provider = "claude".to_string();
        app.projects[0].explicit_default_provider = Some(ProviderKind::from_str("codex"));
        app.projects[0].default_provider = ProviderKind::from_str("codex");
        app.sessions[0].provider = ProviderKind::from_str("gemini");
        app.rebuild_left_items();
        app.selected_left = app
            .left_items()
            .iter()
            .position(|item| matches!(item, LeftItem::Session(index) if *index == 0))
            .expect("select the seeded session");

        let backend = TestBackend::new(200, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");
        let rendered: String = terminal
            .backend()
            .buffer()
            .content
            .iter()
            .map(|cell| cell.symbol())
            .collect();

        assert!(
            rendered.contains("project provider: codex"),
            "expected project provider label, got: {rendered}"
        );
        assert!(
            rendered.contains("global default: claude"),
            "expected global default label, got: {rendered}"
        );
        assert!(
            rendered.contains("current provider: gemini"),
            "expected current provider label, got: {rendered}"
        );
    }

    #[test]
    fn header_uses_default_provider_label_when_project_inherits_global_default() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        app.config.defaults.provider = "codex".to_string();
        app.projects[0].explicit_default_provider = None;
        app.projects[0].default_provider = ProviderKind::from_str("codex");
        app.sessions[0].provider = ProviderKind::from_str("codex");
        app.rebuild_left_items();
        app.selected_left = app
            .left_items()
            .iter()
            .position(|item| matches!(item, LeftItem::Session(index) if *index == 0))
            .expect("select the seeded session");

        let backend = TestBackend::new(200, 24);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");
        let rendered: String = terminal
            .backend()
            .buffer()
            .content
            .iter()
            .map(|cell| cell.symbol())
            .collect();

        assert!(
            rendered.contains("default provider: codex"),
            "expected default provider label, got: {rendered}"
        );
        assert!(
            !rendered.contains("project provider:"),
            "project provider label should be hidden when inheriting global default, got: {rendered}"
        );
        assert!(
            !rendered.contains("global default:"),
            "global default label should be hidden when the project inherits it, got: {rendered}"
        );
        assert!(
            !rendered.contains("current provider:"),
            "current provider label should be hidden when identical, got: {rendered}"
        );
    }

    #[test]
    fn agent_pane_title_keeps_running_provider_after_swap_while_running() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;

        let mut app = test_app(default_bindings());
        app.projects[0].default_provider = ProviderKind::from_str("codex");
        app.sessions[0].provider = ProviderKind::from_str("codex");
        app.sessions[0].status = SessionStatus::Active;
        let session_id = app.sessions[0].id.clone();

        // Spawn a real PTY so the session looks live.
        let worktree = std::path::Path::new(&app.sessions[0].worktree_path);
        let pty = PtyClient::spawn(
            "/bin/sh",
            &["-c".to_string(), "sleep 5".to_string()],
            worktree,
            24,
            80,
            1_000,
        )
        .expect("spawn test agent");
        app.providers.insert(session_id.clone(), pty);

        app.rebuild_left_items();
        app.selected_left = app
            .left_items()
            .iter()
            .position(|item| matches!(item, LeftItem::Session(index) if *index == 0))
            .expect("select the running session");

        // Swap provider while the agent is still running.
        app.open_change_agent_provider_prompt()
            .expect("open picker");
        match &mut app.prompt {
            PromptState::ChangeAgentProvider(prompt) => {
                prompt.selected = prompt
                    .options
                    .iter()
                    .position(|option| option.provider.as_str() == "gemini")
                    .expect("gemini option");
            }
            other => panic!("expected picker, got {other:?}"),
        }
        app.apply_change_agent_provider().expect("apply swap");

        // session.provider is now gemini, but the PTY is still running codex;
        // running_provider_for should reflect that.
        assert_eq!(app.sessions[0].provider.as_str(), "gemini");
        assert_eq!(app.running_provider_for(&app.sessions[0]).as_str(), "codex");

        let backend = TestBackend::new(200, 30);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");
        let rendered: String = terminal
            .backend()
            .buffer()
            .content
            .iter()
            .map(|cell| cell.symbol())
            .collect();
        assert!(
            rendered.contains("Codex agent"),
            "pane title should still say Codex (running) after the swap, got: {rendered}"
        );
        assert!(
            !rendered.contains("Gemini agent"),
            "pane title should NOT claim Gemini is running yet, got: {rendered}"
        );

        // Sidebar entry should show the running → queued transition.
        assert!(
            rendered.contains("(codex → gemini)"),
            "sidebar should show running provider → next provider, got: {rendered}"
        );

        // Tearing down the PTY clears the pin — the next launch will be gemini.
        app.providers.remove(&session_id);
        app.running_provider_pins.remove(&session_id);
        assert_eq!(
            app.running_provider_for(&app.sessions[0]).as_str(),
            "gemini"
        );

        // After the PTY is gone, the sidebar collapses back to a single label.
        let backend = TestBackend::new(200, 30);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| app.render(frame))
            .expect("render frame");
        let rendered: String = terminal
            .backend()
            .buffer()
            .content
            .iter()
            .map(|cell| cell.symbol())
            .collect();
        assert!(
            rendered.contains("(gemini)"),
            "sidebar should collapse to plain (gemini) after the PTY exits, got: {rendered}"
        );
        assert!(
            !rendered.contains("→"),
            "arrow should disappear once no swap is pending, got: {rendered}"
        );
    }
}
