use bytes::Bytes;
use crossterm::event::KeyCode;
use tracing::{debug, warn};

use crate::{
    app::{App, InputSourceId, Mode, TerminalInputTarget},
    input::TerminalKey,
};

struct PreparedPaneInput {
    ws_idx: usize,
    pane_id: crate::layout::PaneId,
    target: TerminalInputTarget,
    bytes: Bytes,
}

enum PreparedPopupInput {
    NotOpen,
    Consumed,
    Bytes {
        target: TerminalInputTarget,
        bytes: Bytes,
    },
}

fn is_modifier_only_key(code: &KeyCode) -> bool {
    matches!(code, KeyCode::Modifier(_))
}

impl App {
    #[cfg(test)]
    pub(crate) fn handle_terminal_key_headless(
        &mut self,
        key: TerminalKey,
    ) -> Option<TerminalInputTarget> {
        self.handle_terminal_key_headless_from(crate::app::LOCAL_INPUT_SOURCE, key)
    }

    pub(crate) fn handle_terminal_key_headless_from(
        &mut self,
        source_id: InputSourceId,
        key: TerminalKey,
    ) -> Option<TerminalInputTarget> {
        match self.prepare_popup_key_forward(key.clone()) {
            PreparedPopupInput::NotOpen => {}
            PreparedPopupInput::Consumed => return None,
            PreparedPopupInput::Bytes { target, bytes } => {
                let Some(runtime) = self.popup_runtime() else {
                    self.close_popup_pane();
                    return None;
                };
                return runtime.try_send_bytes(bytes).is_ok().then_some(target);
            }
        }

        let input = self.prepare_terminal_key_forward(source_id, key)?;
        let sent = self
            .lookup_runtime_sender(input.ws_idx, input.pane_id)
            .is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok());
        sent.then_some(input.target)
    }

    fn prepare_terminal_key_forward(
        &mut self,
        source_id: InputSourceId,
        key: TerminalKey,
    ) -> Option<PreparedPaneInput> {
        let key_event = key.as_key_event();
        if self.try_copy_retained_selection(source_id, key.clone()) {
            return None;
        }

        self.state.clear_selection();
        self.selection_autoscroll_deadline = None;
        self.state.update_dismissed = true;

        if let Some(action) =
            super::terminal_direct_non_indexed_navigation_action(&self.state, &key)
        {
            debug!(
                code = ?key_event.code,
                modifiers = ?key_event.modifiers,
                kind = ?key_event.kind,
                action = ?action,
                "intercepted terminal direct keybinding before forwarding to pane"
            );
            if action == super::navigate::NavigateAction::EditScrollback {
                self.launch_focused_scrollback_editor();
            } else {
                self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct);
            }
            return None;
        }

        if let Some(binding) = super::navigate::command_for_key(
            &self.state,
            &key,
            super::navigate::BindingDispatch::Direct,
        ) {
            debug!(
                code = ?key_event.code,
                modifiers = ?key_event.modifiers,
                kind = ?key_event.kind,
                command = %binding.label,
                "intercepted terminal direct custom command before forwarding to pane"
            );
            self.launch_custom_command(binding, super::navigate::ActionContext::Direct);
            return None;
        }

        if let Some(action) = super::terminal_direct_indexed_navigation_action(&self.state, &key) {
            debug!(
                code = ?key_event.code,
                modifiers = ?key_event.modifiers,
                kind = ?key_event.kind,
                action = ?action,
                "intercepted terminal direct indexed keybinding before forwarding to pane"
            );
            self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct);
            return None;
        }

        if self.state.is_prefix_key(&key) {
            self.state.mode = Mode::Prefix;
            return None;
        }

        if is_modifier_only_key(&key_event.code) {
            debug!(
                code = ?key_event.code,
                modifiers = ?key_event.modifiers,
                kind = ?key_event.kind,
                "dropping modifier-only terminal key event instead of forwarding it to pane"
            );
            return None;
        }

        let ws_idx = self.state.active?;
        let ws = self.state.workspaces.get(ws_idx)?;
        let pane_id = ws.focused_pane_id()?;
        let terminal_id = ws.terminal_id(pane_id)?.clone();
        let rt =
            self.state
                .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?;

        // Intercept plain PageUp/PageDown presses for pane scrollback only
        // when the focused pane looks like a shell transcript. Normal-screen
        // pagers such as `less -X` keep the primary screen but enter
        // application cursor mode while they own special keys.
        // Modified page keys are pane shortcuts, and release events should not
        // produce a second host-scroll action.
        // Only intercept when we know the pane state; if input_state is unknown,
        // fail-open and forward the key to the pane.
        if matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown)
            && key_event.modifiers.is_empty()
        {
            if let Some(host_scroll) = rt.plain_page_keys_use_host_scrollback() {
                if host_scroll {
                    if key_event.kind == crossterm::event::KeyEventKind::Release {
                        return None;
                    }
                    if matches!(
                        key_event.kind,
                        crossterm::event::KeyEventKind::Press
                            | crossterm::event::KeyEventKind::Repeat
                    ) {
                        let lines = self
                            .state
                            .pane_info_by_id(pane_id)
                            .map(|info| info.inner_rect.height as usize)
                            .unwrap_or(10)
                            .max(1);
                        if key_event.code == KeyCode::PageUp {
                            self.state
                                .scroll_pane_up(&self.terminal_runtimes, pane_id, lines);
                        } else {
                            self.state
                                .scroll_pane_down(&self.terminal_runtimes, pane_id, lines);
                        }
                        debug!(
                            code = ?key_event.code,
                            lines,
                            "intercepted page key for pane scrollback"
                        );
                        return None;
                    }
                }
            }
        }

        rt.scroll_reset();
        let protocol = rt.keyboard_protocol();
        let bytes = rt.encode_terminal_key(key.clone());

        if matches!(key_event.code, KeyCode::Esc)
            || key_event
                .modifiers
                .contains(crossterm::event::KeyModifiers::ALT)
        {
            debug!(
                code = ?key_event.code,
                modifiers = ?key_event.modifiers,
                kind = ?key_event.kind,
                protocol = ?protocol,
                encoded = ?bytes,
                "forwarding potentially-ambiguous terminal key to pane"
            );
        }

        if bytes.is_empty() {
            if key.kind != crossterm::event::KeyEventKind::Release
                && !matches!(
                    key.code,
                    KeyCode::CapsLock
                        | KeyCode::ScrollLock
                        | KeyCode::NumLock
                        | KeyCode::PrintScreen
                        | KeyCode::Pause
                        | KeyCode::Menu
                        | KeyCode::KeypadBegin
                        | KeyCode::Media(_)
                        | KeyCode::Modifier(_)
                )
            {
                warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding");
            }
            return None;
        }

        Some(PreparedPaneInput {
            ws_idx,
            pane_id,
            target: TerminalInputTarget { terminal_id },
            bytes: Bytes::from(bytes),
        })
    }

    fn prepare_popup_key_forward(&mut self, key: TerminalKey) -> PreparedPopupInput {
        if self.state.popup_pane.is_none() {
            return PreparedPopupInput::NotOpen;
        }
        let Some(terminal_id) = self
            .state
            .popup_pane
            .as_ref()
            .map(|popup| popup.terminal_id.clone())
        else {
            return PreparedPopupInput::NotOpen;
        };
        let Some(rt) = self.terminal_runtimes.get(&terminal_id) else {
            self.close_popup_pane();
            return PreparedPopupInput::Consumed;
        };
        rt.scroll_reset();
        let bytes = rt.encode_terminal_key(key.clone());
        self.state.mode = Mode::Terminal;
        if bytes.is_empty() {
            PreparedPopupInput::Consumed
        } else {
            PreparedPopupInput::Bytes {
                target: TerminalInputTarget { terminal_id },
                bytes: Bytes::from(bytes),
            }
        }
    }

    pub(crate) fn host_keyboard_report_all_requested(&self) -> bool {
        if self.state.popup_pane.is_none()
            && matches!(self.state.mode, Mode::Prefix | Mode::Navigate)
        {
            return true;
        }

        let runtime = if self.state.popup_pane.is_some() {
            self.popup_runtime()
        } else if self.state.mode == Mode::Terminal {
            self.state.active.and_then(|ws_idx| {
                self.state
                    .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
            })
        } else {
            None
        };

        runtime.is_some_and(crate::terminal::TerminalRuntime::keyboard_report_all_requested)
    }

    fn terminal_input_runtime(
        &self,
        target: &TerminalInputTarget,
    ) -> Option<&crate::terminal::TerminalRuntime> {
        if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) {
            return Some(runtime);
        }
        #[cfg(test)]
        for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
            for tab in &workspace.tabs {
                for (&pane_id, pane) in &tab.panes {
                    if pane.attached_terminal_id == target.terminal_id {
                        return self.state.runtime_for_pane_in_workspace(
                            &self.terminal_runtimes,
                            ws_idx,
                            pane_id,
                        );
                    }
                }
            }
        }
        None
    }

    pub(crate) fn forward_terminal_key_to_target_headless(
        &self,
        target: &TerminalInputTarget,
        key: TerminalKey,
    ) -> bool {
        let Some(runtime) = self.terminal_input_runtime(target) else {
            return false;
        };
        let bytes = runtime.encode_terminal_key(key.clone());
        bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok()
    }

    pub(crate) async fn forward_terminal_key_to_target(
        &self,
        target: &TerminalInputTarget,
        key: TerminalKey,
    ) -> bool {
        let Some(runtime) = self.terminal_input_runtime(target) else {
            return false;
        };
        let bytes = runtime.encode_terminal_key(key.clone());
        bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok()
    }

    fn take_pressed_keys_for_source(
        &mut self,
        source_id: crate::app::InputSourceId,
    ) -> Vec<super::ForwardedInputLease> {
        self.input_leases.remove_source(source_id)
    }

    pub(crate) fn release_input_target_headless(&mut self, target: &TerminalInputTarget) {
        for pressed in self.input_leases.remove_target(target) {
            let release = pressed
                .key
                .with_kind(crossterm::event::KeyEventKind::Release);
            let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release);
        }
    }

    pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
        // Pending URL clicks survive this call; see clear_input_source.
        self.state.clear_chrome_gesture(source_id);
        for pressed in self.take_pressed_keys_for_source(source_id) {
            let release = pressed
                .key
                .with_kind(crossterm::event::KeyEventKind::Release);
            let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release);
        }
    }

    pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) {
        // Pending URL clicks survive this call; see clear_input_source.
        self.state.clear_chrome_gesture(source_id);
        for pressed in self.take_pressed_keys_for_source(source_id) {
            let release = pressed
                .key
                .with_kind(crossterm::event::KeyEventKind::Release);
            let _ = self
                .forward_terminal_key_to_target(&pressed.target, release)
                .await;
        }
    }

    pub(super) async fn handle_terminal_key(
        &mut self,
        key: TerminalKey,
    ) -> Option<TerminalInputTarget> {
        match self.prepare_popup_key_forward(key.clone()) {
            PreparedPopupInput::NotOpen => {}
            PreparedPopupInput::Consumed => return None,
            PreparedPopupInput::Bytes { target, bytes } => {
                let Some(runtime) = self.popup_runtime() else {
                    self.close_popup_pane();
                    return None;
                };
                return runtime.send_bytes(bytes).await.is_ok().then_some(target);
            }
        }

        let input = self.prepare_terminal_key_forward(crate::app::LOCAL_INPUT_SOURCE, key)?;
        let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
            runtime.send_bytes(input.bytes).await.is_ok()
        } else {
            false
        };
        sent.then_some(input.target)
    }
}

#[cfg(test)]
mod tests {
    use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
    use ratatui::layout::Rect;

    #[cfg(target_os = "linux")]
    use super::super::wait_for_detached_process_reap;
    use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
    #[cfg(unix)]
    use super::super::{unique_temp_path, wait_for_file};
    use super::*;
    use crate::{config::Config, events::AppEvent, workspace::Workspace};

    #[cfg(unix)]
    fn app_with_spawned_workspace() -> App {
        let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
        let mut app = App::new(
            &Config::default(),
            true,
            None,
            api_rx,
            crate::api::EventHub::default(),
        );
        app.state.default_shell = "/bin/sh".into();
        let (workspace, terminal, runtime) = Workspace::new(
            std::env::current_dir().unwrap_or_else(|_| "/".into()),
            24,
            80,
            app.state.pane_scrollback_limit_bytes,
            app.state.host_terminal_theme,
            app.state.host_terminal_appearance,
            crate::pane::PaneShellConfig::new(&app.state.default_shell, app.state.shell_mode),
            app.event_tx.clone(),
            app.render_notify.clone(),
            app.render_dirty.clone(),
        )
        .expect("workspace should spawn");
        app.state.workspaces = vec![workspace];
        app.terminal_runtimes.insert(terminal.id.clone(), runtime);
        app.state.terminals.insert(terminal.id.clone(), terminal);
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app
    }

    #[cfg(unix)]
    fn shutdown_test_runtimes(app: &mut App) {
        for (_, runtime) in app.terminal_runtimes.drain() {
            runtime.shutdown();
        }
    }

    fn app_with_screen_bytes(bytes: &[u8]) -> (App, crate::layout::PaneInfo) {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let info = pane_infos[0].clone();
        ws.insert_test_runtime(
            pane_id,
            crate::terminal::TerminalRuntime::test_with_screen_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                bytes,
            ),
        );

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;
        (app, info)
    }

    fn double_click(app: &mut App, col: u16, row: u16) {
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
    }

    fn modified_mouse(
        kind: MouseEventKind,
        col: u16,
        row: u16,
        modifiers: KeyModifiers,
    ) -> crossterm::event::MouseEvent {
        crossterm::event::MouseEvent {
            kind,
            column: col,
            row,
            modifiers,
        }
    }

    fn clipboard_write_content(app: &mut App) -> Vec<u8> {
        match app.event_rx.try_recv().expect("clipboard write event") {
            AppEvent::ClipboardWrite { content } => content,
            event => panic!("unexpected event: {event:?}"),
        }
    }

    fn assert_visible_selection(app: &App) {
        assert!(app
            .state
            .selection
            .as_ref()
            .is_some_and(crate::selection::Selection::is_visible));
    }

    #[cfg(unix)]
    fn install_test_link_handler(app: &mut App) {
        let plugin_root = std::env::temp_dir();
        app.state.installed_plugins = std::collections::HashMap::from([(
            "example.links".to_string(),
            crate::api::schema::InstalledPluginInfo {
                plugin_id: "example.links".into(),
                name: "Links".into(),
                version: "0.1.0".into(),
                min_herdr_version: "0.6.10".into(),
                description: None,
                manifest_path: plugin_root.join("herdr-plugin.toml").display().to_string(),
                plugin_root: plugin_root.display().to_string(),
                enabled: true,
                platforms: None,
                build: Vec::new(),
                startup: Vec::new(),
                actions: vec![crate::api::schema::PluginManifestAction {
                    id: "open".into(),
                    title: "Open link".into(),
                    description: None,
                    contexts: Vec::new(),
                    platforms: None,
                    command: vec!["sh".into(), "-c".into(), ":".into()],
                }],
                events: Vec::new(),
                panes: Vec::new(),
                link_handlers: vec![crate::api::schema::PluginManifestLinkHandler {
                    id: "github-issue".into(),
                    title: "Open GitHub issue".into(),
                    pattern: "^https://github\\.com/[^/]+/[^/]+/(issues|pull)/[0-9]+$".into(),
                    action: "open".into(),
                    platforms: None,
                }],
                source: crate::api::schema::PluginSourceInfo::default(),
                warnings: Vec::new(),
            },
        )]);
    }

    #[tokio::test]
    async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let info = pane_infos[0].clone();
        ws.insert_test_runtime(
            pane_id,
            crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                16 * 1024,
                &numbered_lines_bytes(64),
            ),
        );

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;

        let start_metrics = app
            .state
            .runtime_for_pane(&app.terminal_runtimes, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("initial scroll metrics");
        let start_row = info.inner_rect.y;
        let start_col = info.inner_rect.x + 2;

        app.handle_mouse(mouse(
            MouseEventKind::Down(MouseButton::Left),
            start_col,
            start_row,
        ));
        app.handle_mouse(mouse(
            MouseEventKind::Drag(MouseButton::Left),
            start_col,
            info.inner_rect.y.saturating_sub(1),
        ));

        let end_metrics = app
            .state
            .runtime_for_pane(&app.terminal_runtimes, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("scroll metrics after drag");
        assert_eq!(
            end_metrics.offset_from_bottom,
            start_metrics.offset_from_bottom + 3
        );

        let selection = app.state.selection.as_ref().expect("selection after drag");
        assert!(selection.is_visible());
        assert_eq!(
            selection.ordered_cells(),
            (
                (
                    (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
                    2,
                ),
                (start_metrics.max_offset_from_bottom as u32, 2),
            )
        );
    }

    #[tokio::test]
    async fn releasing_dragged_selection_clears_highlight_after_copy() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let info = pane_infos[0].clone();
        ws.insert_test_runtime(
            pane_id,
            crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                16 * 1024,
                &numbered_lines_bytes(64),
            ),
        );

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;

        let row = info.inner_rect.y;
        let start_col = info.inner_rect.x + 1;
        let end_col = info.inner_rect.x + 4;

        app.handle_mouse(mouse(
            MouseEventKind::Down(MouseButton::Left),
            start_col,
            row,
        ));
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
        assert!(app.state.selection.is_some());

        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));

        assert!(app.state.selection.is_none());
    }

    #[tokio::test]
    async fn drag_copy_then_click_does_not_reuse_double_click_candidate() {
        let (mut app, info) = app_with_screen_bytes(b"alpha beta");
        let row = info.inner_rect.y;
        let start_col = info.inner_rect.x;
        let end_col = info.inner_rect.x + 4;

        app.handle_mouse(mouse(
            MouseEventKind::Down(MouseButton::Left),
            start_col,
            row,
        ));
        assert!(app.last_pane_click.is_some());

        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
        assert!(app.last_pane_click.is_none());

        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
        assert!(app.last_pane_click.is_none());
        assert_eq!(clipboard_write_content(&mut app), b"alpha");

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

        assert!(app.last_pane_click.is_some());
        assert!(app.event_rx.try_recv().is_err());
    }

    #[tokio::test]
    async fn double_click_selects_and_copies_word() {
        let (mut app, info) = app_with_screen_bytes(b"alpha beta-gamma_delta@omega");
        let col = info.inner_rect.x + 13;
        let row = info.inner_rect.y;
        double_click(&mut app, col, row);

        assert_eq!(clipboard_write_content(&mut app), b"beta-gamma_delta@omega");
        assert_visible_selection(&app);
    }

    #[tokio::test]
    async fn copy_on_select_disabled_keeps_drag_selection_without_copying() {
        let (mut app, info) = app_with_screen_bytes(b"alpha beta");
        app.state.copy_on_select = false;
        let row = info.inner_rect.y;
        let start_col = info.inner_rect.x;
        let end_col = info.inner_rect.x + 4;

        app.handle_mouse(mouse(
            MouseEventKind::Down(MouseButton::Left),
            start_col,
            row,
        ));
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
        assert_visible_selection(&app);
        assert!(!app
            .state
            .selection
            .as_ref()
            .is_some_and(crate::selection::Selection::is_finalized));

        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));

        assert_visible_selection(&app);
        assert_eq!(
            app.state
                .selection
                .as_ref()
                .map(crate::selection::Selection::ordered_cells),
            Some(((0, 0), (0, 4)))
        );
        assert!(app
            .state
            .selection
            .as_ref()
            .is_some_and(crate::selection::Selection::is_finalized));
        assert!(app.state.selection_autoscroll.is_none());
        assert!(app.selection_autoscroll_deadline.is_none());
        assert!(app.selection_highlight_clear_deadline.is_none());
        assert!(app.event_rx.try_recv().is_err());
    }

    #[tokio::test]
    async fn copy_on_select_disabled_retains_double_clicked_word_until_shortcut() {
        let (mut app, info) = app_with_screen_bytes(b"alpha beta");
        app.state.copy_on_select = false;
        let col = info.inner_rect.x + 2;
        let row = info.inner_rect.y;

        double_click(&mut app, col, row);

        assert_visible_selection(&app);
        assert!(app.selection_highlight_clear_deadline.is_none());
        assert!(app.event_rx.try_recv().is_err());

        app.handle_terminal_key_headless(TerminalKey::new(
            KeyCode::Char('c'),
            KeyModifiers::CONTROL,
        ));

        assert_eq!(clipboard_write_content(&mut app), b"alpha");
        assert!(app.state.selection.is_none());
    }

    #[tokio::test]
    async fn new_drag_cancels_stale_double_click_highlight_deadline() {
        let (mut app, info) = app_with_screen_bytes(b"alpha beta");
        let row = info.inner_rect.y;
        let word_col = info.inner_rect.x + 2;

        double_click(&mut app, word_col, row);
        assert_eq!(clipboard_write_content(&mut app), b"alpha");
        let stale_deadline = app
            .selection_highlight_clear_deadline
            .expect("double-click highlight deadline");
        app.state.copy_on_select = false;

        let start_col = info.inner_rect.x + 6;
        let end_col = info.inner_rect.x + 9;
        app.handle_mouse(mouse(
            MouseEventKind::Down(MouseButton::Left),
            start_col,
            row,
        ));
        assert!(app.selection_highlight_clear_deadline.is_none());
        app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));

        assert_visible_selection(&app);
        assert!(!app
            .clear_due_selection_highlight(stale_deadline + std::time::Duration::from_millis(1)));
        assert_visible_selection(&app);
        assert!(app.event_rx.try_recv().is_err());
    }

    #[tokio::test]
    async fn ignored_left_down_keeps_double_click_highlight_deadline() {
        let (mut app, info) = app_with_screen_bytes(b"alpha beta");
        let col = info.inner_rect.x + 2;
        let row = info.inner_rect.y;

        double_click(&mut app, col, row);
        assert_eq!(clipboard_write_content(&mut app), b"alpha");
        let deadline = app
            .selection_highlight_clear_deadline
            .expect("double-click highlight deadline");
        app.state.toast = Some(crate::app::state::ToastNotification {
            kind: crate::app::state::ToastKind::Finished,
            title: "finished".into(),
            context: "missing".into(),
            position: None,
            target: Some(crate::app::state::ToastTarget {
                workspace_id: "missing".into(),
                pane_id: info.id,
            }),
        });
        app.state.view.toast_hit_area = Rect::new(0, 0, 1, 1);

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

        assert_visible_selection(&app);
        assert_eq!(app.selection_highlight_clear_deadline, Some(deadline));
        assert!(app.clear_due_selection_highlight(deadline + std::time::Duration::from_millis(1)));
        assert!(app.state.selection.is_none());
    }

    #[tokio::test]
    async fn double_click_uses_display_columns_for_wide_chars() {
        let (mut app, info) = app_with_screen_bytes("echo 你好-world done".as_bytes());
        let col = info.inner_rect.x + 8;
        let row = info.inner_rect.y;
        double_click(&mut app, col, row);

        assert_eq!(clipboard_write_content(&mut app), "你好-world".as_bytes());
        assert_visible_selection(&app);
    }

    #[tokio::test]
    async fn double_click_copies_quoted_path_without_quotes() {
        let line = r#"cat "/tmp/build output/log.txt""#;
        let (mut app, info) = app_with_screen_bytes(line.as_bytes());
        let col = info.inner_rect.x + line.find("output").expect("path segment") as u16;
        let row = info.inner_rect.y;
        double_click(&mut app, col, row);

        assert_eq!(
            clipboard_write_content(&mut app),
            b"/tmp/build output/log.txt"
        );
        assert_visible_selection(&app);
    }

    #[tokio::test]
    async fn double_click_excludes_trailing_punctuation() {
        let (mut app, info) = app_with_screen_bytes(b"done.");
        let col = info.inner_rect.x + 2;
        let row = info.inner_rect.y;
        double_click(&mut app, col, row);

        assert_eq!(clipboard_write_content(&mut app), b"done");
        assert_visible_selection(&app);
    }

    #[tokio::test]
    async fn modified_pane_click_does_not_seed_double_click_copy() {
        let (mut app, info) = app_with_screen_bytes(b"alpha beta");
        let col = info.inner_rect.x + 7;
        let row = info.inner_rect.y;

        app.handle_mouse(modified_mouse(
            MouseEventKind::Down(MouseButton::Left),
            col,
            row,
            KeyModifiers::CONTROL,
        ));
        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));

        assert!(app.event_rx.try_recv().is_err());
        assert!(app.selection_highlight_clear_deadline.is_none());
    }

    #[cfg(target_os = "linux")]
    #[tokio::test]
    async fn ctrl_click_url_reaps_failed_opener() {
        let opener_dir = unique_temp_path("url-opener");
        let record_path = opener_dir.join("record");
        let opener_path = opener_dir.join("xdg-open");
        std::fs::create_dir_all(&opener_dir).expect("fake opener directory");
        std::fs::write(
            &opener_path,
            "#!/bin/sh\nprintf '%s\\n%s\\n' \"$$\" \"$1\" > \"$2\"\nexit 3\n",
        )
        .expect("fake opener script");

        let url = "https://example.com/akbash-2903";
        let line = format!("see {url}");
        let (mut app, info) = app_with_screen_bytes(line.as_bytes());
        let col = info.inner_rect.x + line.find("example").expect("url host") as u16;
        let handled = app.handle_modified_url_click_with(
            41,
            modified_mouse(
                MouseEventKind::Down(MouseButton::Left),
                col,
                info.inner_rect.y,
                KeyModifiers::CONTROL,
            ),
            |clicked_url| {
                std::process::Command::new("/bin/sh")
                    .arg(&opener_path)
                    .arg(clicked_url)
                    .arg(&record_path)
                    .stdin(std::process::Stdio::null())
                    .stdout(std::process::Stdio::null())
                    .stderr(std::process::Stdio::null())
                    .spawn()
                    .map(Some)
            },
        );
        assert!(handled);

        let record = wait_for_file(&record_path);
        let mut lines = record.lines();
        let pid = lines
            .next()
            .expect("opener pid")
            .parse::<u32>()
            .expect("numeric opener pid");
        assert_eq!(lines.next(), Some(url));

        let reaped = wait_for_detached_process_reap(&mut app, pid).await;
        if !reaped {
            unsafe {
                libc::waitpid(pid as libc::pid_t, std::ptr::null_mut(), 0);
            }
        }

        let _ = std::fs::remove_dir_all(&opener_dir);
        assert!(reaped, "failed URL opener child {pid} was not reaped");
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn ctrl_click_url_does_not_forward_release_to_mouse_reporting_pane() {
        let line = "see https://github.com/herdrdev/herdr/issues/1761";
        let col = line.find("github").expect("url host") as u16;
        let (mut app, info) = app_with_screen_bytes(b"");
        let pane_id = app.state.workspaces[0].tabs[0].root_pane;
        let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}");
        let (runtime, mut input_rx) =
            crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                0,
                screen.as_bytes(),
                4,
            );
        app.state.insert_test_runtime(pane_id, runtime);
        install_test_link_handler(&mut app);
        let mut send_mouse = |source_id, kind, column, modifiers| {
            app.handle_mouse_from_input_source(
                source_id,
                modified_mouse(kind, column, info.inner_rect.y, modifiers),
            );
        };
        let down = MouseEventKind::Down(MouseButton::Left);
        let up = MouseEventKind::Up(MouseButton::Left);
        let url_x = info.inner_rect.x + col;

        send_mouse(41, down, url_x, KeyModifiers::CONTROL);
        send_mouse(42, down, info.inner_rect.x, KeyModifiers::empty());
        send_mouse(42, up, info.inner_rect.x, KeyModifiers::empty());
        send_mouse(41, up, url_x, KeyModifiers::empty());

        assert_eq!(app.state.plugin_command_logs.len(), 1);
        assert_eq!(
            input_rx.try_recv().expect("other source mouse down"),
            Bytes::from_static(b"\x1b[<0;1;1M")
        );
        assert_eq!(
            input_rx.try_recv().expect("other source mouse up"),
            Bytes::from_static(b"\x1b[<0;1;1m")
        );
        assert!(
            input_rx.try_recv().is_err(),
            "handled URL click must not leave an unmatched release for the pane"
        );
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn outer_focus_loss_does_not_forward_pending_url_click_release_to_pane() {
        let line = "see https://github.com/herdrdev/herdr/issues/1761";
        let col = line.find("github").expect("url host") as u16;
        let (mut app, info) = app_with_screen_bytes(b"");
        let pane_id = app.state.workspaces[0].tabs[0].root_pane;
        let screen = format!("\x1b[?1049h\x1b[?1000h\x1b[?1006h{line}");
        let (runtime, mut input_rx) =
            crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                0,
                screen.as_bytes(),
                4,
            );
        app.state.insert_test_runtime(pane_id, runtime);
        install_test_link_handler(&mut app);
        let url_x = info.inner_rect.x + col;

        app.handle_mouse_from_input_source(
            41,
            modified_mouse(
                MouseEventKind::Down(MouseButton::Left),
                url_x,
                info.inner_rect.y,
                KeyModifiers::CONTROL,
            ),
        );
        assert_eq!(app.state.plugin_command_logs.len(), 1);

        // Opening the URL raises the browser, so the host terminal loses focus
        // while the button is still down.
        app.route_client_events_from(
            41,
            vec![crate::raw_input::RawInputEvent::OuterFocusLost],
            false,
        );

        app.handle_mouse_from_input_source(
            41,
            modified_mouse(
                MouseEventKind::Up(MouseButton::Left),
                url_x,
                info.inner_rect.y,
                KeyModifiers::empty(),
            ),
        );

        assert!(
            input_rx.try_recv().is_err(),
            "focus loss must not clear a pending URL click, so its release must stay out of the pane"
        );
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() {
        let line = "see https://github.com/herdrdev/herdr/issues/398";
        let col = line.find("github").expect("url host") as u16;

        let (mut ctrl_app, ctrl_info) = app_with_screen_bytes(line.as_bytes());
        install_test_link_handler(&mut ctrl_app);
        ctrl_app.handle_mouse(modified_mouse(
            MouseEventKind::Down(MouseButton::Left),
            ctrl_info.inner_rect.x + col,
            ctrl_info.inner_rect.y,
            KeyModifiers::CONTROL,
        ));

        let ctrl_log = ctrl_app
            .state
            .plugin_command_logs
            .last()
            .expect("ctrl-click should start plugin link handler");
        assert_eq!(ctrl_log.plugin_id, "example.links");
        assert_eq!(ctrl_log.action_id.as_deref(), Some("open"));

        let (mut super_app, super_info) = app_with_screen_bytes(line.as_bytes());
        install_test_link_handler(&mut super_app);
        super_app.handle_mouse(modified_mouse(
            MouseEventKind::Down(MouseButton::Left),
            super_info.inner_rect.x + col,
            super_info.inner_rect.y,
            KeyModifiers::SUPER,
        ));

        assert!(super_app.state.plugin_command_logs.is_empty());
    }

    #[tokio::test]
    async fn pane_cell_url_resolver_finds_visible_url() {
        let line = "see https://example.com/pr/307.";
        let (app, info) = app_with_screen_bytes(line.as_bytes());
        let pane_id = app.state.workspaces[0].tabs[0].root_pane;
        let col = line.find("example").expect("url host") as u16;

        assert_eq!(
            app.state
                .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, col)
                .as_deref(),
            Some("https://example.com/pr/307")
        );
        assert_eq!(
            app.state.url_at_pane_cell(
                &app.terminal_runtimes,
                pane_id,
                0,
                info.inner_rect.width - 1
            ),
            None
        );
    }

    #[tokio::test]
    async fn pane_cell_url_resolver_finds_soft_wrapped_url() {
        let (_app, info) = app_with_screen_bytes(b"");
        let prefix = "https://example.com/";
        let padding = "a".repeat(info.inner_rect.width as usize - prefix.len());
        let url = format!("{prefix}{padding}tail");
        let (app, _info) = app_with_screen_bytes(url.as_bytes());
        let pane_id = app.state.workspaces[0].tabs[0].root_pane;

        assert_eq!(
            app.state
                .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1)
                .as_deref(),
            Some(url.as_str())
        );
    }

    #[tokio::test]
    async fn pane_cell_url_resolver_does_not_shift_after_zero_width_mark() {
        let url = "https://example.com/mark";
        let screen = format!("e\u{301} {url}");
        let (app, _info) = app_with_screen_bytes(screen.as_bytes());
        let pane_id = app.state.workspaces[0].tabs[0].root_pane;

        assert_eq!(
            app.state
                .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 2)
                .as_deref(),
            Some(url)
        );
    }

    #[tokio::test]
    async fn pane_cell_url_resolver_handles_hard_newline_after_full_row() {
        let (_app, info) = app_with_screen_bytes(b"");
        let full_row = "x".repeat(info.inner_rect.width as usize);
        let url = "https://example.com/next";
        let screen = format!("{full_row}\n{url}");
        let (app, _info) = app_with_screen_bytes(screen.as_bytes());
        let pane_id = app.state.workspaces[0].tabs[0].root_pane;

        assert_eq!(
            app.state
                .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1)
                .as_deref(),
            None
        );
        assert_eq!(
            app.state
                .url_at_pane_cell(&app.terminal_runtimes, pane_id, 2, 1)
                .as_deref(),
            Some(url)
        );
    }

    #[tokio::test]
    async fn render_stream_does_not_synthesize_soft_wrapped_url_hyperlinks() {
        let (_app, info) = app_with_screen_bytes(b"");
        let prefix = "https://example.com/";
        let padding = "b".repeat(info.inner_rect.width as usize - prefix.len());
        let url = format!("{prefix}{padding}tail");
        let (app, _info) = app_with_screen_bytes(url.as_bytes());

        let links =
            crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);

        assert!(links.is_empty());
    }

    #[tokio::test]
    async fn render_stream_does_not_synthesize_url_hyperlinks_after_zero_width_mark() {
        let url = "https://example.com/mark";
        let screen = format!("e\u{301} {url}");
        let (app, _info) = app_with_screen_bytes(screen.as_bytes());

        let links =
            crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);

        assert!(links.is_empty());
    }

    #[tokio::test]
    async fn render_stream_does_not_synthesize_hard_newline_plain_url_hyperlinks() {
        let (_app, info) = app_with_screen_bytes(b"");
        let full_row = "x".repeat(info.inner_rect.width as usize);
        let url = "https://example.com/next";
        let screen = format!("{full_row}\n{url}");
        let (app, _info) = app_with_screen_bytes(screen.as_bytes());
        let links =
            crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);

        assert!(links.is_empty());
    }

    #[tokio::test]
    async fn render_stream_exports_osc8_hyperlink_metadata() {
        let uri = "https://example.com/target";
        let (mut app, _info) =
            app_with_screen_bytes(format!("\x1b]8;;{uri}\x1b\\label\x1b]8;;\x1b\\").as_bytes());
        let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry(
            &mut app.state,
            &app.terminal_runtimes,
            ratatui::layout::Rect::new(0, 0, 106, 20),
            false,
            crate::kitty_graphics::HostCellSize::default(),
        );
        let links =
            crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
        let frame = crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks(
            &buffer, cursor, &links,
        );
        let ((x, y), symbol, _) = links
            .iter()
            .find(|(_, symbol, link_uri)| symbol == "l" && link_uri == uri)
            .expect("OSC 8 link cell");
        let linked_cell_index = usize::from(*y) * usize::from(frame.width) + usize::from(*x);

        assert_eq!(frame.hyperlinks, vec![uri.to_owned()]);
        assert_eq!(symbol, "l");
        assert_eq!(frame.cells[linked_cell_index].hyperlink, Some(0));
    }

    #[tokio::test]
    async fn pane_cell_url_resolver_prefers_osc8_hyperlink() {
        let (app, _info) = app_with_screen_bytes(
            b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\",
        );
        let pane_id = app.state.workspaces[0].tabs[0].root_pane;

        assert_eq!(
            app.state
                .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 1)
                .as_deref(),
            Some("https://example.com/hidden-target")
        );
    }

    #[tokio::test]
    async fn double_click_highlight_clears_after_short_delay() {
        let (mut app, info) = app_with_screen_bytes(b"copied");
        let col = info.inner_rect.x + 2;
        let row = info.inner_rect.y;
        double_click(&mut app, col, row);
        assert_eq!(clipboard_write_content(&mut app), b"copied");

        app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row));

        assert!(app.event_rx.try_recv().is_err());
        assert!(app.state.selection.is_some());
        let deadline = app
            .selection_highlight_clear_deadline
            .expect("highlight clear deadline");
        assert!(app.handle_scheduled_tasks(deadline + std::time::Duration::from_millis(1), false));
        assert!(app.state.selection.is_none());
    }

    #[tokio::test]
    async fn copy_on_select_disabled_still_forwards_mouse_reporting_gestures() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let info = pane_infos[0].clone();
        let (runtime, mut input_rx) =
            crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                0,
                b"\x1b[?1002h\x1b[?1006h",
                4,
            );
        ws.insert_test_runtime(pane_id, runtime);
        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;
        app.state.copy_on_select = false;

        let col = info.inner_rect.x + 2;
        let row = info.inner_rect.y + 3;
        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row));
        app.handle_mouse(mouse(
            MouseEventKind::Drag(MouseButton::Left),
            col + 1,
            row + 1,
        ));
        app.handle_mouse(mouse(
            MouseEventKind::Up(MouseButton::Left),
            col + 1,
            row + 1,
        ));

        assert!(app.event_rx.try_recv().is_err());
        assert!(app.state.selection.is_none());
        assert!(app.selection_highlight_clear_deadline.is_none());
        assert_eq!(
            input_rx.try_recv().expect("forwarded left mouse down"),
            Bytes::from_static(b"\x1b[<0;3;4M")
        );
        assert_eq!(
            input_rx.try_recv().expect("forwarded left mouse drag"),
            Bytes::from_static(b"\x1b[<32;4;5M")
        );
        assert_eq!(
            input_rx.try_recv().expect("forwarded left mouse up"),
            Bytes::from_static(b"\x1b[<0;4;5m")
        );
        assert!(input_rx.try_recv().is_err());
    }

    #[tokio::test]
    async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let info = pane_infos[0].clone();
        ws.insert_test_runtime(
            pane_id,
            crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                16 * 1024,
                &numbered_lines_bytes(64),
            ),
        );

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;

        let start_metrics = app
            .state
            .runtime_for_pane(&app.terminal_runtimes, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("initial scroll metrics");
        let top_row = info.inner_rect.y;
        let col = info.inner_rect.x + 2;

        app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row));
        app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row));

        let end_metrics = app
            .state
            .runtime_for_pane(&app.terminal_runtimes, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("scroll metrics after wheel");
        assert_eq!(
            end_metrics.offset_from_bottom,
            start_metrics.offset_from_bottom + 3
        );

        let selection = app.state.selection.as_ref().expect("selection after wheel");
        assert!(selection.is_visible());
        assert_eq!(
            selection.ordered_cells(),
            (
                (
                    (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
                    2,
                ),
                (start_metrics.max_offset_from_bottom as u32, 2),
            )
        );
    }

    #[tokio::test]
    async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() {
        let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
        let mut app = App::new(
            &Config::default(),
            true,
            None,
            api_rx,
            crate::api::EventHub::default(),
        );
        app.state.workspaces = vec![Workspace::test_new("test")];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
        app.state.view.pane_infos = app.state.workspaces[0]
            .active_tab()
            .unwrap()
            .layout
            .panes(Rect::new(0, 0, 80, 24));
        let focused_before = app.state.workspaces[0].layout.focused();
        app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h");

        app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT))
            .await;

        assert_ne!(app.state.workspaces[0].layout.focused(), focused_before);
        assert_eq!(app.state.mode, Mode::Terminal);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn terminal_direct_edit_scrollback_opens_editor_pane() {
        let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
        let mut app = App::new(
            &Config::default(),
            true,
            None,
            api_rx,
            crate::api::EventHub::default(),
        );
        let mut workspace = Workspace::test_new("test");
        let root_pane = workspace.tabs[0].root_pane;
        workspace.tabs[0].runtimes.insert(
            root_pane,
            crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
                20,
                5,
                4096,
                b"alpha\nbeta\n",
            ),
        );
        app.state.workspaces = vec![workspace];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;

        let output_path = unique_temp_path("direct-edit-scrollback");
        let previous_editor = std::env::var_os("EDITOR");
        std::env::set_var(
            "EDITOR",
            format!("sh -c 'cp \"$1\" {}' sh", output_path.display()),
        );
        app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::direct("ctrl+alt+e");

        app.handle_terminal_key(TerminalKey::new(
            KeyCode::Char('e'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        ))
        .await;

        match previous_editor {
            Some(value) => std::env::set_var("EDITOR", value),
            None => std::env::remove_var("EDITOR"),
        }

        let content = wait_for_file(&output_path);
        assert!(content.contains("alpha"));
        assert!(content.contains("beta"));
        assert_eq!(app.state.mode, Mode::Terminal);

        let _ = std::fs::remove_file(output_path);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn direct_custom_command_runs_before_forwarding_to_pane() {
        let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
        let mut app = App::new(
            &Config::default(),
            true,
            None,
            api_rx,
            crate::api::EventHub::default(),
        );
        app.state.workspaces = vec![Workspace::test_new("test")];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;

        let output_path = unique_temp_path("direct-custom-command");
        let command = format!("printf direct > '{}'", output_path.display());
        app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
            bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
            label: "ctrl+alt+g".into(),
            command,
            action: crate::config::CustomCommandAction::Shell,
            description: None,
            width: None,
            height: None,
        }];

        app.handle_terminal_key(TerminalKey::new(
            KeyCode::Char('g'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        ))
        .await;

        assert_eq!(wait_for_file(&output_path), "direct");
        assert_eq!(app.state.mode, Mode::Terminal);
        let _ = std::fs::remove_file(output_path);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn direct_custom_pane_command_opens_overlay_pane() {
        let mut app = app_with_spawned_workspace();

        app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
            bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
            label: "ctrl+alt+g".into(),
            command: "printf direct-pane".into(),
            action: crate::config::CustomCommandAction::Pane,
            description: None,
            width: None,
            height: None,
        }];

        app.handle_terminal_key(TerminalKey::new(
            KeyCode::Char('g'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        ))
        .await;

        assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
        assert!(app.state.workspaces[0].tabs[0].zoomed);
        assert_eq!(app.state.mode, Mode::Terminal);

        shutdown_test_runtimes(&mut app);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn direct_custom_popup_command_opens_layout_neutral_popup() {
        let mut app = app_with_spawned_workspace();

        app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
            bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
            label: "ctrl+alt+g".into(),
            command: "sleep 1".into(),
            action: crate::config::CustomCommandAction::Popup,
            description: None,
            width: Some(crate::popup_size::PopupSize::Cells(60)),
            height: Some(crate::popup_size::PopupSize::Cells(12)),
        }];

        app.handle_terminal_key(TerminalKey::new(
            KeyCode::Char('g'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        ))
        .await;

        assert!(app.state.popup_pane.is_some());
        assert!(!app
            .popup_runtime()
            .unwrap()
            .agent_detection_enabled_for_test());
        assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
        assert!(!app.state.workspaces[0].tabs[0].zoomed);
        assert_eq!(app.state.mode, Mode::Terminal);
        let snapshot = crate::persist::capture(
            &app.state.workspaces,
            &app.state.terminals,
            &app.terminal_runtimes,
            app.state.active,
            app.state.selected,
            app.state.sidebar_width,
            app.state.sidebar_section_split,
            app.state.collapsed_space_keys.clone(),
        );
        assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 1);
        assert!(matches!(
            snapshot.workspaces[0].tabs[0].layout,
            crate::persist::LayoutSnapshot::Pane(_)
        ));

        shutdown_test_runtimes(&mut app);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn direct_custom_popup_command_closes_after_exit() {
        let mut app = app_with_spawned_workspace();
        let focused_pane = app.state.workspaces[0].focused_pane_id().unwrap();
        let focused_pane_id = app.public_pane_id(0, focused_pane).unwrap();

        let output_path = unique_temp_path("custom-popup-command");
        let command = format!(
            "printf '%s|%s' \"${{HERDR_PANE_ID-unset}}\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
            output_path.display()
        );
        app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
            bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
            label: "ctrl+alt+g".into(),
            command,
            action: crate::config::CustomCommandAction::Popup,
            description: None,
            width: None,
            height: None,
        }];

        app.handle_terminal_key(TerminalKey::new(
            KeyCode::Char('g'),
            KeyModifiers::CONTROL | KeyModifiers::ALT,
        ))
        .await;

        assert_eq!(
            wait_for_file(&output_path),
            format!("unset|{focused_pane_id}")
        );
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
        while std::time::Instant::now() < deadline {
            app.drain_internal_events();
            if app.state.popup_pane.is_none() {
                break;
            }
        }

        assert!(app.state.popup_pane.is_none());
        assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);

        shutdown_test_runtimes(&mut app);
        let _ = std::fs::remove_file(output_path);
    }

    #[tokio::test]
    async fn popup_forwards_escape_instead_of_closing() {
        let mut app = app_for_mouse_test();
        let (runtime, mut rx) =
            crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
                40,
                2,
                1024,
                b"one\r\ntwo\r\nthree\r\n",
                4,
            );
        runtime.scroll_up(1);
        assert!(runtime
            .scroll_metrics()
            .is_some_and(|metrics| metrics.offset_from_bottom > 0));
        app.install_test_popup_runtime(runtime);
        app.state.mode = Mode::Settings;

        app.handle_terminal_key_headless(TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()));

        assert_eq!(rx.try_recv().unwrap().as_ref(), b"\x1b");
        assert!(app.state.popup_pane.is_some());
        assert_eq!(
            app.popup_runtime()
                .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
                .map(|metrics| metrics.offset_from_bottom),
            Some(0)
        );
    }

    #[tokio::test]
    async fn local_popup_input_waits_for_channel_capacity() {
        let mut app = app_for_mouse_test();
        let (runtime, mut rx) =
            crate::terminal::TerminalRuntime::test_with_channel_capacity(40, 2, 1);
        runtime
            .try_send_bytes(Bytes::from_static(b"queued"))
            .unwrap();
        app.install_test_popup_runtime(runtime);
        app.state.mode = Mode::Settings;

        let mut send = Box::pin(
            app.handle_terminal_key(TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty())),
        );
        assert!(
            tokio::time::timeout(std::time::Duration::from_millis(20), &mut send)
                .await
                .is_err()
        );

        assert_eq!(rx.recv().await.unwrap().as_ref(), b"queued");
        send.await;
        assert_eq!(rx.recv().await.unwrap().as_ref(), b"x");
    }

    #[tokio::test]
    async fn alt_backspace_is_forwarded_to_focused_pane() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24));
        let info = pane_infos[0].clone();
        let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(
            info.inner_rect.width,
            info.inner_rect.height,
        );
        ws.tabs[0].runtimes.insert(pane_id, runtime);

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;

        let key = crate::input::parse_terminal_key_sequence("\x1b\x7f").unwrap();
        app.handle_terminal_key_headless(key);

        let bytes = rx.try_recv().unwrap();
        assert_eq!(bytes.as_ref(), b"\x1b\x7f");
        assert!(rx.try_recv().is_err());
    }

    fn app_with_plain_scrollback(
        line_count: usize,
    ) -> (App, crate::layout::PaneId, crate::layout::PaneInfo) {
        let mut app = app_for_mouse_test();
        let mut workspace = Workspace::test_new("test");
        let pane_id = workspace.tabs[0].root_pane;
        let pane_infos = workspace.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let pane_info = pane_infos[0].clone();
        workspace.tabs[0].runtimes.insert(
            pane_id,
            crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
                pane_info.inner_rect.width,
                pane_info.inner_rect.height,
                16 * 1024,
                &numbered_lines_bytes(line_count),
            ),
        );
        app.state.workspaces = vec![workspace];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;
        (app, pane_id, pane_info)
    }

    fn pane_scroll_offset(app: &App, pane_id: crate::layout::PaneId) -> usize {
        app.state
            .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("pane scroll metrics")
            .offset_from_bottom
    }

    fn physical_page_up(repeat_count: u16) -> TerminalKey {
        TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()).with_windows_record(
            crate::input::WindowsKeyRecord {
                key_down: true,
                repeat_count,
                virtual_key_code: 0x21,
                virtual_scan_code: 0x49,
                unicode: 0,
                control_key_state: 0x0100,
            },
        )
    }

    #[tokio::test]
    async fn page_up_scrolls_plain_shell_pane() {
        let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64);
        assert_eq!(pane_scroll_offset(&app, pane_id), 0);

        app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));

        assert_eq!(
            pane_scroll_offset(&app, pane_id),
            pane_info.inner_rect.height as usize
        );
    }

    #[tokio::test]
    async fn consumed_page_up_preserves_separate_and_grouped_repeats() {
        let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256);
        let page_up = physical_page_up(1);
        app.route_client_events(
            vec![
                crate::raw_input::RawInputEvent::Key(page_up.clone()),
                crate::raw_input::RawInputEvent::Key(
                    page_up.clone().with_kind(KeyEventKind::Repeat),
                ),
                crate::raw_input::RawInputEvent::Key(
                    page_up.clone().with_kind(KeyEventKind::Release),
                ),
            ],
            false,
        );

        assert_eq!(
            pane_scroll_offset(&app, pane_id),
            pane_info.inner_rect.height as usize * 2
        );

        app.route_client_events(
            vec![crate::raw_input::RawInputEvent::Key(physical_page_up(3))],
            false,
        );

        assert_eq!(
            pane_scroll_offset(&app, pane_id),
            pane_info.inner_rect.height as usize * 5
        );
    }

    #[tokio::test]
    async fn runtime_consumed_page_up_preserves_separate_and_grouped_repeats() {
        let (mut app, pane_id, pane_info) = app_with_plain_scrollback(256);
        let page_up = physical_page_up(1);
        for key in [
            page_up.clone(),
            page_up.clone().with_kind(KeyEventKind::Repeat),
            page_up.clone().with_kind(KeyEventKind::Release),
        ] {
            app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(key))
                .await;
        }

        assert_eq!(
            pane_scroll_offset(&app, pane_id),
            pane_info.inner_rect.height as usize * 2
        );

        app.handle_raw_input_event(crate::raw_input::RawInputEvent::Key(physical_page_up(3)))
            .await;

        assert_eq!(
            pane_scroll_offset(&app, pane_id),
            pane_info.inner_rect.height as usize * 5
        );
    }

    #[tokio::test]
    async fn consumed_repeat_that_becomes_forwarded_acquires_pane_ownership() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let first_pane = ws.tabs[0].root_pane;
        let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal);
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let first_info = pane_infos
            .iter()
            .find(|info| info.id == first_pane)
            .expect("first pane info");
        ws.tabs[0].runtimes.insert(
            first_pane,
            crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
                first_info.inner_rect.width,
                first_info.inner_rect.height,
                16 * 1024,
                &numbered_lines_bytes(128),
            ),
        );
        let (second_runtime, mut second_rx) =
            crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
                80,
                24,
                0,
                b"\x1b[?1h\x1b[>15u",
                3,
            );
        ws.tabs[0].runtimes.insert(second_pane, second_runtime);
        ws.tabs[0].layout.focus_pane(first_pane);

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;

        let page_up = physical_page_up(1);
        app.route_client_events(
            vec![crate::raw_input::RawInputEvent::Key(page_up.clone())],
            false,
        );
        assert!(app.state.focus_pane_in_workspace(0, second_pane));
        app.route_client_events(
            vec![crate::raw_input::RawInputEvent::Key(
                page_up.clone().with_kind(KeyEventKind::Repeat),
            )],
            false,
        );
        assert!(app.state.focus_pane_in_workspace(0, first_pane));
        app.route_client_events(
            vec![
                crate::raw_input::RawInputEvent::Key(
                    page_up.clone().with_kind(KeyEventKind::Repeat),
                ),
                crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)),
            ],
            false,
        );

        let first_repeat = second_rx.try_recv().expect("first forwarded repeat");
        let second_repeat = second_rx.try_recv().expect("owned repeat");
        let release = second_rx.try_recv().expect("owned release");
        assert_eq!(first_repeat, second_repeat);
        assert_ne!(second_repeat, release);
        assert!(second_rx.try_recv().is_err());
        assert!(app.input_leases.is_empty());
    }

    #[tokio::test]
    async fn missing_popup_runtime_suppresses_grouped_and_later_repeats() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let (runtime, mut input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24);
        ws.tabs[0].runtimes.insert(pane_id, runtime);
        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;

        let (popup_runtime, _popup_rx) =
            crate::terminal::TerminalRuntime::test_with_channel(40, 12);
        app.install_test_popup_runtime(popup_runtime);
        let popup_terminal_id = app
            .state
            .popup_pane
            .as_ref()
            .expect("popup installed")
            .terminal_id
            .clone();
        app.terminal_runtimes.remove(&popup_terminal_id);

        let key = TerminalKey::new(KeyCode::Char('x'), KeyModifiers::empty()).with_repeat_count(3);
        app.route_client_events(
            vec![
                crate::raw_input::RawInputEvent::Key(key.clone()),
                crate::raw_input::RawInputEvent::Key(
                    key.clone()
                        .with_kind(KeyEventKind::Repeat)
                        .with_repeat_count(1),
                ),
                crate::raw_input::RawInputEvent::Key(key.with_kind(KeyEventKind::Release)),
            ],
            false,
        );

        assert!(app.state.popup_pane.is_none());
        assert!(input_rx.try_recv().is_err());
        assert!(app.input_leases.is_empty());
    }

    #[tokio::test]
    async fn consumed_repeat_stays_suppressed_after_context_returns() {
        let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(128);
        let page_up = physical_page_up(1);
        app.route_client_events(
            vec![crate::raw_input::RawInputEvent::Key(page_up.clone())],
            false,
        );
        let after_press = pane_scroll_offset(&app, pane_id);

        let (popup_runtime, mut popup_rx) =
            crate::terminal::TerminalRuntime::test_with_channel(40, 12);
        app.install_test_popup_runtime(popup_runtime);
        app.route_client_events(
            vec![crate::raw_input::RawInputEvent::Key(
                page_up.clone().with_kind(KeyEventKind::Repeat),
            )],
            false,
        );
        app.close_popup_pane();
        app.route_client_events(
            vec![
                crate::raw_input::RawInputEvent::Key(
                    page_up.clone().with_kind(KeyEventKind::Repeat),
                ),
                crate::raw_input::RawInputEvent::Key(page_up.with_kind(KeyEventKind::Release)),
            ],
            false,
        );

        assert_eq!(pane_scroll_offset(&app, pane_id), after_press);
        assert!(popup_rx.try_recv().is_err());
        assert!(app.input_leases.is_empty());
    }

    #[tokio::test]
    async fn page_down_returns_to_bottom_after_page_up() {
        let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64);

        app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
        assert!(pane_scroll_offset(&app, pane_id) > 0);

        app.handle_terminal_key_headless(TerminalKey::new(
            KeyCode::PageDown,
            KeyModifiers::empty(),
        ));
        assert_eq!(pane_scroll_offset(&app, pane_id), 0);
    }

    #[tokio::test]
    async fn page_up_release_does_not_scroll_plain_shell_pane_again() {
        let (mut app, pane_id, pane_info) = app_with_plain_scrollback(64);

        app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
        let after_press = pane_scroll_offset(&app, pane_id);
        assert_eq!(after_press, pane_info.inner_rect.height as usize);

        app.handle_terminal_key_headless(
            TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty())
                .with_kind(KeyEventKind::Release),
        );

        assert_eq!(pane_scroll_offset(&app, pane_id), after_press);
    }

    #[tokio::test]
    async fn modified_page_up_does_not_host_scroll_plain_shell_pane() {
        let (mut app, pane_id, _pane_info) = app_with_plain_scrollback(64);

        app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::CONTROL));

        assert_eq!(pane_scroll_offset(&app, pane_id), 0);
    }

    #[tokio::test]
    async fn page_up_forwarded_to_mouse_reporting_pane() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let info = pane_infos[0].clone();
        let mut bytes = b"\x1b[?1002h".to_vec();
        bytes.extend_from_slice(&numbered_lines_bytes(64));
        ws.tabs[0].runtimes.insert(
            pane_id,
            crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                16 * 1024,
                &bytes,
            ),
        );

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;

        let start_metrics = app
            .state
            .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("initial scroll metrics");
        assert_eq!(start_metrics.offset_from_bottom, 0);

        app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));

        let end_metrics = app
            .state
            .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("scroll metrics after PageUp");
        // Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom.
        assert_eq!(end_metrics.offset_from_bottom, 0);
    }

    #[tokio::test]
    async fn page_up_forwarded_to_primary_screen_application_cursor_pane() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let info = pane_infos[0].clone();
        let mut bytes = b"\x1b[?1h".to_vec();
        bytes.extend_from_slice(&numbered_lines_bytes(64));
        let (runtime, mut input_rx) =
            crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                16 * 1024,
                &bytes,
                4,
            );
        ws.tabs[0].runtimes.insert(pane_id, runtime);

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;

        let start_metrics = app
            .state
            .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("initial scroll metrics");
        assert_eq!(start_metrics.offset_from_bottom, 0);

        app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));

        let forwarded = input_rx.try_recv().expect("forwarded PageUp");
        assert_eq!(forwarded.as_ref(), b"\x1b[5~");
        let end_metrics = app
            .state
            .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
            .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
            .expect("scroll metrics after PageUp");
        assert_eq!(end_metrics.offset_from_bottom, 0);
    }

    #[tokio::test]
    async fn page_up_scrolls_shell_like_decckm_with_bracketed_paste() {
        let mut app = app_for_mouse_test();
        let mut ws = Workspace::test_new("test");
        let pane_id = ws.tabs[0].root_pane;
        let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
        let info = pane_infos[0].clone();
        // zsh enables DECCKM via smkx and bracketed paste together; bash/fish do not.
        let mut bytes = b"\x1b[?1h\x1b[?2004h".to_vec();
        bytes.extend_from_slice(&numbered_lines_bytes(64));
        let (runtime, mut input_rx) =
            crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
                info.inner_rect.width,
                info.inner_rect.height,
                16 * 1024,
                &bytes,
                4,
            );
        ws.tabs[0].runtimes.insert(pane_id, runtime);

        app.state.workspaces = vec![ws];
        app.state.active = Some(0);
        app.state.selected = 0;
        app.state.mode = Mode::Terminal;
        app.state.view.pane_infos = pane_infos;

        app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));

        assert!(
            input_rx.try_recv().is_err(),
            "PageUp should not reach the shell"
        );
        assert_eq!(
            pane_scroll_offset(&app, pane_id),
            info.inner_rect.height as usize
        );
    }
}
