//! Secondary pane input: scrollback keys and search, todo/tool-usage panes, //! background tasks, subagent catalog, and the pane-aware scroll router. use super::{ActivePane, AgentPane, AgentView, overlay_action_to_outcome, resolve_action}; use crate::actions::{ActionId, ActionRegistry, When}; use crate::app::actions::Action; use crate::app::app_view::InputOutcome; use crate::key; use crate::scrollback::ScrollbackSearchState; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; impl AgentView { /// Scrollback-focused key handling. /// /// When the block viewer is open, routes keys to the viewer. /// Otherwise, uses ActionRegistry for keybinding lookup. pub(super) fn handle_scrollback_key( &mut self, key: &KeyEvent, registry: &ActionRegistry, ) -> InputOutcome { if let Some(outcome) = self.handle_scrollback_search_key(key) { return outcome; } let viewer_has_input = self .block_viewer .as_ref() .is_some_and(|v| v.list_state.input_mode().is_some()); let allow_i_alt = self.vim_mode; if !viewer_has_input && (matches!(key.code, KeyCode::Tab | KeyCode::Char(' ')) || (allow_i_alt && matches!(key.code, KeyCode::Char('i')))) { if self.question_view.is_some() { self.set_active_pane(AgentPane::Prompt, false); return InputOutcome::Changed; } if key.code == KeyCode::Tab && self.tasks.overlay.visible && self.set_active_pane(AgentPane::Tasks, false) { self.tasks.overlay.focused = true; return InputOutcome::Changed; } return InputOutcome::Action(Action::FocusPrompt); } if key!(Enter).matches(key) && let Some(url) = self.highlighted_link_url().map(String::from) { self.highlighted_link_idx = None; return InputOutcome::Action(Action::OpenUrl(url)); } if key!(Enter).matches(key) && !self.scrollback.is_selected_group_header() && let Some(idx) = self.scrollback.selected() && self .scrollback .entry(idx) .is_some_and(|e| e.block.is_user_prompt()) && self.enter_inline_edit(idx) { return InputOutcome::Changed; } if key!(Enter).matches(key) && !self.scrollback.is_selected_group_header() && let Some(idx) = self.scrollback.selected() && let Some(entry) = self.scrollback.entry(idx) && let crate::scrollback::block::RenderBlock::Subagent(ref sb) = entry.block { let child_sid = sb.child_session_id.clone(); if self.subagent_views.contains_key(&child_sid) { self.open_subagent_fullscreen(child_sid); return InputOutcome::Changed; } } if self.vim_mode && key!('x').matches(key) && !self.scrollback.is_selected_group_header() && let Some(idx) = self.scrollback.selected() && let Some(entry) = self.scrollback.entry(idx) && let crate::scrollback::block::RenderBlock::BgTask(ref bt) = entry.block && self .session .bg_tasks .get(&bt.task_id) .is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running) { return InputOutcome::Action(Action::KillBgTask(bt.task_id.clone())); } if key.code == KeyCode::Esc && key.modifiers.is_empty() && self.persistent_text_selection.take().is_some() { self.table_selection_geometry = None; self.selection_created_at = None; return InputOutcome::Changed; } if key.code == KeyCode::Esc && key.modifiers.is_empty() && self.highlighted_link_idx.take().is_some() { return InputOutcome::Changed; } if self.vim_mode && key!('/').matches(key) && self.no_input_overlay_pending() && self.btw_state.is_none() { if self.scrollback.is_empty() { return InputOutcome::ActionThenForward(Action::FocusPrompt); } self.open_scrollback_search(None); return InputOutcome::Changed; } if key!('r', CONTROL).matches(key) && (registry.find(ActionId::ToggleMouseCapture).is_some() || crate::app::mouse_reporting_toggle_enabled()) { return InputOutcome::Action(Action::ToggleMouseCapture); } if let Some(outcome) = resolve_action(registry.lookup_with_mode(key, When::ScrollbackFocused, self.vim_mode)) { return outcome; } if !self.vim_mode && let KeyCode::Char(c) = key.code && (c.is_ascii_alphabetic() || c == '/') && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) { return InputOutcome::ActionThenForward(Action::FocusPrompt); } InputOutcome::Unchanged } /// Focus the scrollback pane and open an incremental search over it. /// /// Shared by the vim `/` key and the `/find` slash command so both entry /// points land in the same state, refocusing scrollback when `/find` is run /// from the prompt in simple mode. /// /// Only opens the search if the pane switch succeeds: a dirty queued-prompt /// edit blocks the switch (showing the confirm modal) and returns false, so /// opening search then would strand an invisible session on the prompt — the /// search bar and key handling are gated on scrollback being focused. /// /// `initial_query` (the `/find ` argument) is fed through the same /// keystroke path so a pre-filled search behaves identically to typing the /// word into the bar: a composing regex query with immediate highlights. pub(crate) fn open_scrollback_search(&mut self, initial_query: Option<&str>) { if self.set_active_pane(AgentPane::Scrollback, false) { self.scrollback_search = Some(ScrollbackSearchState::open()); if let Some(query) = initial_query { self.set_scrollback_search_query(query); } } } /// Step to the next (`forward`) or previous match and scroll it into view. /// Shared by the `n`/`N` keys and the `↓`/`↑` arrows. fn navigate_search(&mut self, forward: bool) -> Option { if let Some(search) = self.scrollback_search.as_mut() { if forward { search.next(); } else { search.prev(); } } self.reveal_current_search_match(); Some(InputOutcome::Changed) } /// Bottom scrollback rows to reserve for the search UI (divider + bar): /// two when search is active, clamped to the rows that actually exist so a /// very short region never pushes the bar below the scrollback rect. pub(super) fn search_reserved_rows(scrollback_height: u16, search_active: bool) -> u16 { if search_active { scrollback_height.min(2) } else { 0 } } /// Handle a key while the scrollback search overlay is open. /// /// Returns `None` when search isn't open (or, while browsing, for keys that /// should fall through to normal scrollback handling). While composing the /// query the bar is modal and swallows other keys. fn handle_scrollback_search_key(&mut self, key: &KeyEvent) -> Option { let composing = self.scrollback_search.as_ref()?.is_composing(); let non_text = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER; if key.code == KeyCode::Esc { self.scrollback_search = None; return Some(InputOutcome::Changed); } if key.modifiers.is_empty() { match key.code { KeyCode::Down => return self.navigate_search(true), KeyCode::Up => return self.navigate_search(false), _ => {} } } if composing { match key.code { KeyCode::Enter => { if self.scrollback_search.as_ref()?.query().is_empty() { self.scrollback_search = None; } else { if let Some(search) = self.scrollback_search.as_mut() { search.accept(); } self.reveal_current_search_match(); } Some(InputOutcome::Changed) } KeyCode::Backspace => { let mut q = self.scrollback_search.as_ref()?.query().to_string(); q.pop(); self.set_scrollback_search_query(&q); Some(InputOutcome::Changed) } KeyCode::Char(c) if !key.modifiers.intersects(non_text) => { let mut q = self.scrollback_search.as_ref()?.query().to_string(); q.push(c); self.set_scrollback_search_query(&q); Some(InputOutcome::Changed) } _ => Some(InputOutcome::Unchanged), } } else { match key.code { KeyCode::Char('n') if key.modifiers.is_empty() => self.navigate_search(true), KeyCode::Char('N') if !key.modifiers.intersects(non_text) => { self.navigate_search(false) } _ => None, } } } /// Enqueue `query` for the background scan. Results (and the reveal) arrive /// later via [`poll_scrollback_search`](Self::poll_scrollback_search); the /// highlight updates immediately because it reads the UI-side matcher. fn set_scrollback_search_query(&mut self, query: &str) { if let Some(search) = self.scrollback_search.as_mut() { search.update_query(query, &self.scrollback); } } /// Poll the background search daemon for new results, revealing the freshly /// parked match when they change. Returns `true` if the UI should redraw. pub(crate) fn poll_scrollback_search(&mut self) -> bool { let changed = self.scrollback_search.as_mut().is_some_and(|s| s.poll()); if changed { self.reveal_current_search_match(); } changed } /// Scroll the current search match into view via `reveal_entry_line`. fn reveal_current_search_match(&mut self) { let target = self .scrollback_search .as_ref() .and_then(|s| s.current()) .map(|m| (m.entry_id, m.line_in_entry)); if let Some((id, line)) = target && let Some(idx) = self.scrollback.index_of_id(id) { self.scrollback.reveal_entry_line(idx, line); } } /// Todo-pane-focused key handling. /// /// Routes structural keys through the shared overlay handler, then /// content keys through `TodoPane::handle_key`. pub(super) fn handle_todo_key( &mut self, key: &KeyEvent, _registry: &ActionRegistry, ) -> InputOutcome { use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key}; if key!('t', CONTROL).matches(key) { self.todo.overlay.toggle(); self.todo.on_state_change(); if !self.todo.overlay.focused { return InputOutcome::Action(Action::FocusScrollback); } return InputOutcome::Changed; } let has_input = self.todo.list_state.input_mode().is_some(); let action = handle_overlay_key(&mut self.todo.overlay, key).or_else(|| { if !has_input { handle_overlay_nav_key(&mut self.todo.overlay, key) } else { None } }); if let Some(action) = action { self.todo.on_state_change(); if !self.todo.overlay.visible || !self.todo.overlay.focused { self.set_active_pane(AgentPane::Scrollback, false); } return overlay_action_to_outcome(action); } if self.todo.handle_key(key) { InputOutcome::Changed } else { InputOutcome::Unchanged } } /// Bg-task-pane-focused key handling. pub(super) fn handle_bg_tasks_key( &mut self, key: &KeyEvent, _registry: &ActionRegistry, ) -> InputOutcome { use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key}; use crate::views::tasks_pane::TaskEntry; if key!('b', CONTROL).matches(key) { self.tasks.overlay.toggle(); self.tasks.on_state_change(); if !self.tasks.overlay.focused { return InputOutcome::Action(Action::FocusScrollback); } return InputOutcome::Changed; } if self.tasks.list_state.input_mode().is_none() && let Some(group) = self.tasks.selected_header_group() { if key!(Right).matches(key) { self.tasks.set_group_collapsed(group, false); return InputOutcome::Changed; } if key!(Left).matches(key) { self.tasks.set_group_collapsed(group, true); return InputOutcome::Changed; } } let is_open_key = self.tasks.list_state.input_mode().is_none() && (key!(Enter).matches(key) || key!('f', CONTROL).matches(key)); if is_open_key { if let Some(group) = self.tasks.selected_header_group() { self.tasks.toggle_group(group); return InputOutcome::Changed; } match self.tasks.selected_entry() { Some(TaskEntry::BgTask { task_id, .. }) => { let task_id = task_id.clone(); if let Some(task) = self.session.bg_tasks.get(&task_id) { let entry_id = task .scrollback_entry_id .unwrap_or_else(|| crate::scrollback::entry::EntryId::new(0)); let is_running = task.status == crate::app::agent::BgTaskStatus::Running; self.block_viewer = Some(crate::views::block_viewer::BlockViewerPane::for_bg_task( entry_id, &task_id, &task.stdout, is_running, )); self.set_active_pane(AgentPane::Scrollback, true); return InputOutcome::Changed; } } Some(TaskEntry::Agent { child_session_id, .. }) => { let child_sid = child_session_id.clone(); if self.subagent_views.contains_key(&child_sid) { self.open_subagent_fullscreen(child_sid); return InputOutcome::Changed; } } Some(TaskEntry::Scheduled { .. }) => {} Some(TaskEntry::Header { .. }) => {} None => {} } } if key!('x').matches(key) && self.tasks.list_state.input_mode().is_none() { match self.tasks.selected_entry() { Some(TaskEntry::BgTask { task_id, .. }) => { let task_id = task_id.clone(); if self .session .bg_tasks .get(&task_id) .is_some_and(|t| t.status == crate::app::agent::BgTaskStatus::Running) { return InputOutcome::Action(Action::KillBgTask(task_id)); } } Some(TaskEntry::Agent { subagent_id, .. }) => { let subagent_id = subagent_id.clone(); if self.subagent_sessions.values().any(|s| { s.subagent_id.as_ref() == subagent_id && s.is_running() && !s.pending_kill }) { return InputOutcome::Action(Action::KillSubagent(subagent_id)); } } Some(TaskEntry::Scheduled { task_id, .. }) => { return InputOutcome::Action(Action::CancelScheduledTask(task_id.clone())); } Some(TaskEntry::Header { .. }) => {} None => {} } } if key!('y').matches(key) && self.tasks.list_state.input_mode().is_none() && let Some(task_id) = self.tasks.selected_task_id().map(|s| s.to_string()) && let Some(task) = self.session.bg_tasks.get(&task_id) && !task.stdout.is_empty() { let text = task.stdout.clone(); self.copy_to_clipboard(&text); return InputOutcome::Changed; } if key!(Tab).matches(key) && self.tasks.list_state.input_mode().is_none() { self.tasks.overlay.focused = false; return InputOutcome::Action(Action::FocusPrompt); } let has_input = self.tasks.list_state.input_mode().is_some(); let action = handle_overlay_key(&mut self.tasks.overlay, key).or_else(|| { if !has_input { handle_overlay_nav_key(&mut self.tasks.overlay, key) } else { None } }); if let Some(action) = action { self.tasks.on_state_change(); if !self.tasks.overlay.visible || !self.tasks.overlay.focused { self.set_active_pane(AgentPane::Scrollback, false); } return overlay_action_to_outcome(action); } if self.tasks.handle_key(key) { InputOutcome::Changed } else { InputOutcome::Unchanged } } /// Subagent-pane-focused key handling. pub(super) fn handle_catalog_key( &mut self, key: &KeyEvent, _registry: &ActionRegistry, ) -> InputOutcome { use crate::views::overlay::{handle_overlay_key, handle_overlay_nav_key}; let has_input = self.catalog.list_state.input_mode().is_some(); let action = handle_overlay_key(&mut self.catalog.overlay, key).or_else(|| { if !has_input { handle_overlay_nav_key(&mut self.catalog.overlay, key) } else { None } }); if let Some(action) = action { self.catalog.on_state_change(); if !self.catalog.overlay.visible || !self.catalog.overlay.focused { self.set_active_pane(AgentPane::Scrollback, false); } return overlay_action_to_outcome(action); } if key.code == crossterm::event::KeyCode::Enter && key.modifiers == crossterm::event::KeyModifiers::NONE { if let Some((kind, name)) = self.catalog.selected_entry() { return InputOutcome::Action(Action::ViewCatalogEntry { kind: kind.to_owned(), name: name.to_owned(), }); } return InputOutcome::Unchanged; } if self.catalog.handle_key(key) { InputOutcome::Changed } else { InputOutcome::Unchanged } } /// Handle a normalized scroll event at a screen position. /// /// Hit-tests against pane areas to decide what to scroll: /// - Scrollback area → scroll the scrollback (uses accelerated line count) /// - Prompt area → forward to textarea (which has its own scroll logic) /// /// Positive `lines` = scroll down, negative = scroll up. pub fn handle_scroll(&mut self, lines: i32, col: u16, row: u16) { if let Some(ref mut modal) = self.active_modal { use crate::views::modal::ActiveModal; match modal { ActiveModal::CommandPalette { state, .. } | ActiveModal::ArgPicker { state, .. } | ActiveModal::SessionPicker { state, .. } | ActiveModal::DocPicker { state, .. } => { let delta = lines.unsigned_abs() as usize; let current = state.scroll_offset.unwrap_or(0); let new_offset = if lines > 0 { current + delta } else { current.saturating_sub(delta) }; state.scroll_offset = Some(new_offset); state.hovered = None; return; } ActiveModal::DocViewer { scroll, .. } | ActiveModal::RememberNoteReview { scroll, .. } => { crate::views::modal::apply_doc_scroll_delta(scroll, lines); return; } _ => {} } } if let Some(ref mut viewer) = self.block_viewer { viewer.handle_scroll(lines); return; } if self.rewind_state.is_some() { if let Some(ref mut rw) = self.rewind_state { crate::views::rewind::move_cursor(&mut rw.phase, lines.signum()); self.sync_rewind_anchor_to_picker(); } return; } self.dismiss_jump_picker_if_suppressed(); if let Some(ref mut js) = self.jump_state { crate::views::jump::move_cursor(js, lines.signum()); self.sync_jump_preview(); return; } if let Some(ref mut viewer) = self.line_viewer { if let Some(area) = viewer.last_popup_area && area.contains((col, row).into()) { viewer .list_state .handle_scroll_event(lines, col, row, &viewer.lines); } return; } if let Some(ref mut btw) = self.btw_state && matches!(btw, crate::views::btw_overlay::BtwOverlayState::Done { .. }) && self.last_btw_area.area() > 0 && self.last_btw_area.contains((col, row).into()) { use crate::views::btw_overlay::DONE_MAX_BODY_LINES; let max_body = DONE_MAX_BODY_LINES as usize; let content_width = self.last_btw_area.width.saturating_sub(4) as usize; let max_off = btw.max_scroll_offset(content_width, max_body); if lines > 0 { btw.scroll_down(lines as usize, max_off); } else { btw.scroll_up((-lines) as usize); } return; } if let Some(hd_area) = self.history_dropdown_area && hd_area.contains((col, row).into()) && self.prompt.history_search.is_active() { let moved = if lines > 0 { self.prompt.history_search.move_down() } else if lines < 0 { self.prompt.history_search.move_up() } else { false }; if moved && self.prompt.history_search.is_browse() { self.populate_prompt_from_history_selection(); } return; } if let Some(dd_area) = self.dropdown_items_area && dd_area.contains((col, row).into()) { self.prompt .file_search .move_selection(lines.signum() as isize); return; } if let Some(dd_area) = self.slash_dropdown_items_area && dd_area.contains((col, row).into()) { self.prompt.slash_scroll_selection(lines.signum() as isize); self.prompt.slash_preview_current_selection(); return; } if let Some(dd_area) = self.completion_dropdown_items_area && dd_area.contains((col, row).into()) { self.prompt .completion_dropdown_scroll(lines.signum() as isize); return; } if self.question_view.is_some() && self.pane_areas.prompt.contains((col, row).into()) { if self .inline_prompt_area .is_some_and(|r| r.contains((col, row).into())) { let kind = if lines > 0 { MouseEventKind::ScrollDown } else { MouseEventKind::ScrollUp }; let event = MouseEvent { kind, column: col, row, modifiers: crossterm::event::KeyModifiers::NONE, }; let _ = self.prompt.handle_mouse(&event); } else if let Some((scroll_top, scroll_bottom)) = self.question_scroll_region && row >= scroll_top && row < scroll_bottom { self.apply_question_scroll(lines); } return; } let target = self .pane_areas .hit_test(col, row) .unwrap_or(ActivePane::Scrollback); match target { ActivePane::Scrollback => { if lines > 0 { self.scrollback.scroll_down(lines as u16); } else { self.scrollback.scroll_up((-lines) as u16); } } ActivePane::Todo => { self.todo.handle_scroll(lines, col, row); } ActivePane::Queue => { self.queue.handle_scroll(lines, col, row); } ActivePane::Tasks => { self.tasks.handle_scroll(lines, col, row); } ActivePane::Catalog => { self.catalog.handle_scroll(lines, col, row); } ActivePane::Prompt => { if self.question_view.is_some() { return; } let kind = if lines > 0 { MouseEventKind::ScrollDown } else { MouseEventKind::ScrollUp }; let event = MouseEvent { kind, column: col, row, modifiers: KeyModifiers::NONE, }; self.prompt.handle_mouse(&event); } } } } #[cfg(test)] mod scroll_granularity_tests { use super::super::test_fixtures::make_agent; use crate::views::suggestion_controller::{ CompletionDropdownState, CompletionItemParsed, SuggestionSource, }; use ratatui::layout::Rect; /// Selection dropdowns step exactly one item per wheel dispatch: a /// 3-line notch (or accelerated trackpad flush) must not skip items. #[test] fn wheel_notch_over_slash_dropdown_moves_selection_one_step() { let mut agent = make_agent(); agent.prompt.set_text("/"); agent.prompt.refresh_slash(&agent.session.models); assert!(agent.prompt.slash_open(), "precondition: dropdown open"); assert!( agent.prompt.slash_snapshot().matches.len() >= 3, "precondition: enough builtin commands to skip over" ); assert_eq!(agent.prompt.slash_snapshot().selected, 0); agent.slash_dropdown_items_area = Some(Rect::new(0, 0, 40, 8)); agent.handle_scroll(3, 5, 4); assert_eq!( agent.prompt.slash_snapshot().selected, 1, "3-line wheel notch must move the slash selection by exactly 1" ); agent.handle_scroll(-3, 5, 4); assert_eq!( agent.prompt.slash_snapshot().selected, 0, "-3-line wheel notch must move the slash selection by exactly -1" ); } fn completion_item(label: &str) -> CompletionItemParsed { CompletionItemParsed { display: label.into(), description: String::new(), insert_text: label.into(), source: SuggestionSource::History, priority: 0, replace_range: None, token_text: None, truncated: false, } } #[test] fn wheel_notch_over_completion_dropdown_moves_selection_one_step() { let mut agent = make_agent(); agent.prompt.suggestions.dropdown = CompletionDropdownState { open: true, items: vec![ completion_item("a"), completion_item("b"), completion_item("c"), ], selected: 0, ..Default::default() }; agent.completion_dropdown_items_area = Some(Rect::new(0, 0, 40, 8)); agent.handle_scroll(3, 5, 4); assert_eq!( agent.prompt.suggestions.dropdown.selected, 1, "3-line wheel notch must move the completion selection by exactly 1" ); agent.handle_scroll(-3, 5, 4); assert_eq!( agent.prompt.suggestions.dropdown.selected, 0, "-3-line wheel notch must move the completion selection by exactly -1" ); } }