Files
Kigi-CLI/crates/codegen/kigi-tui/src/app/modals.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

2777 lines
122 KiB
Rust

//! Modal dialog handling for [`AgentView`]: the `handle_modal_key` /
//! `handle_modal_mouse` input dispatchers, the command palette / arg picker /
//! doc picker input handlers, and the active-modal draw dispatch.
//!
//! Extracted from `agent_view.rs` as a sibling `impl AgentView` block (same
//! pattern as `queue_edit.rs` and `mouse.rs`).
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use super::actions::Action;
use super::agent_view::{AgentView, active_contexts_for_pane, apply_settings_outcome};
use super::app_view::InputOutcome;
use crate::theme::Theme;
use crate::views::modal::{self, ActiveModal};
impl AgentView {
/// `suggest_args` falls back to model rows when the query is not in effort
/// phase. Model-phase reasoning rows use a trailing space in `insert_text`;
/// effort rows do not. Require a non-empty list with no trailing-space
/// rows before treating the picker as effort phase.
fn arg_items_look_like_effort_phase(items: &[crate::slash::command::ArgItem]) -> bool {
!items.is_empty()
&& items
.iter()
.all(|item| !item.insert_text.ends_with(char::is_whitespace))
}
/// Step the model ArgPicker from effort phase back to the model list.
/// Returns `true` if the modal was updated (caller should not fully close).
fn try_arg_picker_step_back_from_effort(&mut self) -> bool {
Self::try_arg_picker_step_back_from_effort_modal(
&mut self.active_modal,
&self.prompt.slash_controller,
&self.session.models,
&self.session.cwd,
)
}
fn try_arg_picker_step_back_from_effort_modal(
active_modal: &mut Option<ActiveModal>,
slash_controller: &crate::slash::SlashController,
models: &crate::acp::model_state::ModelState,
cwd: &std::path::Path,
) -> bool {
let Some(ActiveModal::ArgPicker {
command,
args_query,
..
}) = active_modal.as_ref()
else {
return false;
};
if args_query.is_empty() || !matches!(command.as_str(), "model" | "m") {
return false;
}
let command = command.clone();
let Some(cmd) = slash_controller.registry().get(&command) else {
return false;
};
let ctx = crate::slash::command::AppCtx {
models,
cwd,
screen_mode: slash_controller.screen_mode(),
};
let Some(model_items) = cmd.suggest_args(&ctx, "") else {
return false;
};
if model_items.is_empty() {
return false;
}
if let Some(ActiveModal::ArgPicker {
args_query,
items,
original_items,
state,
..
}) = active_modal.as_mut()
{
args_query.clear();
*items = model_items.clone();
*original_items = model_items;
// Model list is type-to-find: reopen input-default like the initial /model open.
*state = crate::views::picker::PickerState::input_active();
}
true
}
/// Handle a key press while a modal dialog is active.
///
/// Matches the pressed character against the modal's options and resolves
/// the result. All non-matching keys are consumed (blocked).
pub(super) fn handle_modal_key(&mut self, key: &KeyEvent) -> InputOutcome {
use crate::views::modal::ActiveModal;
use crate::views::modal_window::{self as mw, ModalWindowOutcome};
let Some(ref mut modal) = self.active_modal else {
return InputOutcome::Changed;
};
// Picker-based modals: route Esc through ModalWindow chrome first,
// then delegate remaining keys to the picker input handler.
if matches!(
modal,
ActiveModal::CommandPalette { .. }
| ActiveModal::ArgPicker { .. }
| ActiveModal::SessionPicker { .. }
| ActiveModal::DocPicker { .. }
) {
let (window, query_empty, esc_clears) = match modal {
ActiveModal::CommandPalette { window, state, .. } => {
(window, state.query.is_empty(), true)
}
ActiveModal::ArgPicker { window, state, .. } => {
(window, state.query.is_empty(), false)
}
ActiveModal::SessionPicker { window, state, .. } => {
(window, state.query.is_empty(), false)
}
ActiveModal::DocPicker { window, state, .. } => {
(window, state.query.is_empty(), true)
}
_ => unreachable!(),
};
// These modals don't use fold; fold_info is None so
// Left/Right/h/l return Unhandled and reach the picker.
let chrome_cfg = mw::ModalWindowConfig {
title: "",
tabs: None,
shortcuts: &[],
sizing: mw::ModalSizing::default(),
fold_info: None,
};
let outcome = mw::handle_modal_key(window, key, &chrome_cfg);
match outcome {
ModalWindowOutcome::CloseRequested => {
if esc_clears && !query_empty {
match modal {
ActiveModal::CommandPalette { state, .. } => {
state.query.clear();
state.query_cursor = 0;
state.selected = 0;
state.scroll_offset = None;
}
ActiveModal::DocPicker { state, .. } => {
state.query.clear();
state.query_cursor = 0;
state.selected = 0;
state.scroll_offset = None;
}
_ => {}
}
return InputOutcome::Changed;
}
// Otherwise delegate close to the picker handler which
// knows about palette snapshots / restore logic.
if matches!(self.active_modal, Some(ActiveModal::DocPicker { .. })) {
let ev = crossterm::event::Event::Key(*key);
return self.handle_doc_input(&ev);
}
let ev = crossterm::event::Event::Key(*key);
return self.handle_palette_or_arg_input(&ev);
}
ModalWindowOutcome::Unhandled => {
// Non-Esc key (including Left/Right/h/l):
// forward to picker input handler.
if matches!(self.active_modal, Some(ActiveModal::DocPicker { .. })) {
let ev = crossterm::event::Event::Key(*key);
return self.handle_doc_input(&ev);
}
let ev = crossterm::event::Event::Key(*key);
return self.handle_palette_or_arg_input(&ev);
}
_ => return InputOutcome::Changed,
}
}
// RememberNoteReview: modal preview for # remember notes.
if let ActiveModal::RememberNoteReview {
ref mut scroll,
ref mut showing_enhanced,
ref enhanced_content,
ref mut cached_lines,
ref mut window,
..
} = *modal
{
let chrome_cfg = mw::ModalWindowConfig {
title: "",
tabs: None,
shortcuts: &[],
sizing: mw::ModalSizing::default(),
fold_info: None,
};
match mw::handle_modal_key(window, key, &chrome_cfg) {
mw::ModalWindowOutcome::CloseRequested => {
self.active_modal = None;
return InputOutcome::Changed;
}
mw::ModalWindowOutcome::Handled => return InputOutcome::Changed,
mw::ModalWindowOutcome::Unhandled => {}
_ => {}
}
match key.code {
KeyCode::Enter => {
return InputOutcome::Action(Action::SaveRememberNoteFromModal);
}
KeyCode::Char('y') if key.modifiers.is_empty() => {
return InputOutcome::Action(Action::SaveRememberNoteFromModal);
}
KeyCode::Tab => {
if enhanced_content.is_some() {
*showing_enhanced = !*showing_enhanced;
*cached_lines = None;
*scroll = 0;
return InputOutcome::Changed;
}
return InputOutcome::Unchanged;
}
KeyCode::Down | KeyCode::Char('j') => {
*scroll = scroll.saturating_add(1);
return InputOutcome::Changed;
}
KeyCode::Up | KeyCode::Char('k') => {
*scroll = scroll.saturating_sub(1);
return InputOutcome::Changed;
}
KeyCode::PageDown => {
*scroll = scroll.saturating_add(10);
return InputOutcome::Changed;
}
KeyCode::PageUp => {
*scroll = scroll.saturating_sub(10);
return InputOutcome::Changed;
}
_ => return InputOutcome::Unchanged,
}
}
// DocViewer: route through ModalWindow chrome, then handle scroll.
if let ActiveModal::DocViewer {
window,
previous_palette,
standalone,
..
} = modal
{
let chrome_cfg = mw::ModalWindowConfig {
title: "",
tabs: None,
shortcuts: &[],
sizing: mw::ModalSizing::default(),
fold_info: None,
};
let outcome = mw::handle_modal_key(window, key, &chrome_cfg);
match outcome {
ModalWindowOutcome::CloseRequested => {
if *standalone {
self.active_modal = None;
} else {
// Esc in DocViewer -> back to DocPicker list.
// Shuttle the palette snapshot so DocPicker can restore it on its own Esc.
let prev = previous_palette.take();
self.active_modal = Some(crate::views::modal::howto_list_modal(prev));
}
return InputOutcome::Changed;
}
ModalWindowOutcome::Unhandled => {
// Scroll keys (no fold for doc viewer).
let ev = crossterm::event::Event::Key(*key);
return self.handle_doc_input(&ev);
}
_ => return InputOutcome::Changed,
}
}
// ShortcutsHelp: modal window chrome first, then picker / detail.
if let ActiveModal::ShortcutsHelp {
entries,
state,
window,
filter_active,
collapsed_sections,
expanded_ids,
mode,
} = modal
{
use crate::views::shortcuts_help::{self, ShortcutsHelpOutcome};
let searching = state.search_active || !state.query.is_empty();
if mode.is_browse() && searching && key.code == KeyCode::Esc {
state.query.clear();
state.query_cursor = 0;
state.search_active = false;
state.selected = 0;
return InputOutcome::Changed;
}
// Detail owns Esc (back to browse); skip chrome so it doesn't close the modal.
let footer = if mode.is_detail() {
shortcuts_help::modal_footer_detail()
} else {
shortcuts_help::modal_footer(*filter_active)
};
let chrome_cfg = mw::ModalWindowConfig {
title: "Keyboard Shortcuts",
tabs: None,
shortcuts: &footer,
sizing: crate::views::shortcuts_help::modal_sizing(
self.scrollback.appearance().prompt.compact,
),
fold_info: None,
};
if mode.is_browse() {
match mw::handle_modal_key(window, key, &chrome_cfg) {
mw::ModalWindowOutcome::CloseRequested => {
self.active_modal = None;
return InputOutcome::Changed;
}
mw::ModalWindowOutcome::Unhandled => {}
_ => return InputOutcome::Changed,
}
}
match shortcuts_help::handle_input(
key,
entries,
state,
*filter_active,
collapsed_sections,
expanded_ids,
mode,
) {
ShortcutsHelpOutcome::Close => {
self.active_modal = None;
return InputOutcome::Changed;
}
ShortcutsHelpOutcome::ToggleFilter => {
*filter_active = !*filter_active;
state.selected = 0;
return InputOutcome::Changed;
}
ShortcutsHelpOutcome::ToggleSection(idx) => {
shortcuts_help::toggle_membership(collapsed_sections, idx);
return InputOutcome::Changed;
}
ShortcutsHelpOutcome::ToggleExpand(action_id) => {
shortcuts_help::toggle_membership(expanded_ids, action_id);
return InputOutcome::Changed;
}
ShortcutsHelpOutcome::Changed => return InputOutcome::Changed,
ShortcutsHelpOutcome::Unchanged => return InputOutcome::Unchanged,
}
}
// MemoryBrowser: route through ModalWindow chrome, then delegate.
if let ActiveModal::MemoryBrowser { state } = modal {
// When the filter input is focused, Esc exits filter mode
// instead of closing the modal. Handle before modal chrome.
if matches!(
state.mode,
crate::views::memory_modal::MemoryModalMode::FilterFocused
) {
return crate::views::memory_modal::handle_memory_key(state, key);
}
let chrome_cfg = mw::ModalWindowConfig {
title: "",
tabs: None,
shortcuts: &[],
sizing: mw::ModalSizing::default(),
fold_info: None,
};
let outcome = mw::handle_modal_key(&mut state.window, key, &chrome_cfg);
match outcome {
ModalWindowOutcome::CloseRequested => {
self.active_modal = None;
return InputOutcome::Changed;
}
ModalWindowOutcome::Unhandled => {
return crate::views::memory_modal::handle_memory_key(state, key);
}
_ => return InputOutcome::Changed,
}
}
// Settings: route through ModalWindow chrome, then delegate.
if let ActiveModal::Settings { state } = modal {
// Sub-mode short-circuit: FilterFocused, PickingEnum, PickingGroup,
// and EditingValue own their own Esc/keystroke semantics.
if matches!(
state.mode,
crate::views::settings_modal::SettingsModalMode::FilterFocused
| crate::views::settings_modal::SettingsModalMode::PickingEnum { .. }
| crate::views::settings_modal::SettingsModalMode::PickingGroup { .. }
| crate::views::settings_modal::SettingsModalMode::EditingValue { .. }
) {
let out = crate::views::settings_modal::handle_settings_key(state, key);
return apply_settings_outcome(self, out);
}
let chrome_cfg = mw::ModalWindowConfig {
title: "",
tabs: None,
shortcuts: &[],
sizing: mw::ModalSizing::default(),
fold_info: None,
};
let chrome_outcome = mw::handle_modal_key(&mut state.window, key, &chrome_cfg);
match chrome_outcome {
ModalWindowOutcome::CloseRequested => {
self.active_modal = None;
return InputOutcome::Changed;
}
ModalWindowOutcome::Unhandled => {
let out = crate::views::settings_modal::handle_settings_key(state, key);
return apply_settings_outcome(self, out);
}
_ => return InputOutcome::Changed,
}
}
// ResetSettingsConfirm: y/n routing. Handled before generic
// char-match so Esc/F2/Ctrl+, route to Cancel (not modal close).
if let Some(ActiveModal::ResetSettingsConfirm { modal, .. }) = self.active_modal.as_ref() {
let resolved = match key.code {
KeyCode::Esc => Some(crate::views::modal::ResetSettingsResult::Cancel),
KeyCode::F(2) => Some(crate::views::modal::ResetSettingsResult::Cancel),
KeyCode::Char(',')
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::SUPER) =>
{
Some(crate::views::modal::ResetSettingsResult::Cancel)
}
// Only bare keystrokes — Ctrl+Y must not fire Reset.
KeyCode::Char(c) if key.modifiers.is_empty() => modal.resolve(c).copied(),
_ => None,
};
return match resolved {
Some(choice) => InputOutcome::Action(Action::ConfirmResetSetting { choice }),
None => InputOutcome::Changed,
};
}
// EditConfirm: single char matching.
let ch = match key.code {
KeyCode::Char(c) => c,
KeyCode::Esc => {
self.active_modal = None;
return InputOutcome::Changed;
}
_ => return InputOutcome::Changed,
};
// Take the modal so we can match on it and modify self.
let Some(modal) = self.active_modal.take() else {
return InputOutcome::Changed;
};
match modal {
ActiveModal::EditConfirm {
modal: confirm,
pending_target,
} => self.handle_edit_confirm_choice(confirm, pending_target, ch),
ActiveModal::CommandPalette { .. }
| ActiveModal::ArgPicker { .. }
| ActiveModal::SessionPicker { .. }
| ActiveModal::DocPicker { .. }
| ActiveModal::DocViewer { .. }
| ActiveModal::ShortcutsHelp { .. }
| ActiveModal::MemoryBrowser { .. }
| ActiveModal::Settings { .. }
| ActiveModal::ResetSettingsConfirm { .. }
| ActiveModal::RememberNoteReview { .. } => unreachable!(),
}
}
/// Arg picker input (separate from command palette to avoid borrow conflicts
/// when stepping back from the model effort phase via slash registry + session).
fn handle_arg_picker_input(&mut self, ev: &crossterm::event::Event) -> InputOutcome {
use crate::views::picker::{PickerConfig, PickerOutcome, handle_picker_input};
enum ArgPickerStep {
Selected(crate::slash::command::ArgItem),
Closed,
FilterChanged,
}
let (command_clone, in_effort_phase, entry_count) = match self.active_modal.as_ref() {
Some(ActiveModal::ArgPicker {
command,
args_query,
items,
..
}) => (command.clone(), !args_query.is_empty(), items.len()),
_ => return InputOutcome::Changed,
};
let config = PickerConfig {
title: None,
show_search_hint: false,
expandable: false,
esc_clears_query: false,
shortcuts: Some(crate::views::picker::picker_shortcuts()),
pending_hint: None,
non_selectable: &[],
non_selectable_clickable: &[],
shortcuts_area: None,
tabs: None,
active_tab: 0,
filter_label: None,
filter_key_hint: None,
filter_active: false,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first: crate::appearance::cache::load_vim_mode(),
};
let step = {
let Some(ActiveModal::ArgPicker { items, state, .. }) = self.active_modal.as_mut()
else {
return InputOutcome::Changed;
};
match handle_picker_input(ev, state, entry_count, &config) {
PickerOutcome::Selected(i) => match items.get(i).cloned() {
Some(item) => ArgPickerStep::Selected(item),
None => return InputOutcome::Changed,
},
PickerOutcome::Closed => ArgPickerStep::Closed,
PickerOutcome::Changed => ArgPickerStep::FilterChanged,
PickerOutcome::Unchanged => return InputOutcome::Unchanged,
_ => return InputOutcome::Changed,
}
};
match step {
ArgPickerStep::FilterChanged => {
if let Some(ActiveModal::ArgPicker {
items,
original_items,
state,
..
}) = self.active_modal.as_mut()
{
let q = state.query.to_lowercase();
*items = original_items
.iter()
.filter(|item| {
q.is_empty()
|| item.match_text.to_lowercase().contains(&q)
|| item.display.to_lowercase().contains(&q)
|| item.description.to_lowercase().contains(&q)
})
.cloned()
.collect();
state.selected = state.selected.min(items.len().saturating_sub(1));
}
InputOutcome::Changed
}
ArgPickerStep::Closed => {
if in_effort_phase && self.try_arg_picker_step_back_from_effort() {
return InputOutcome::Changed;
}
let snapshot = match self.active_modal.as_mut() {
Some(ActiveModal::ArgPicker {
previous_palette, ..
}) => previous_palette.take(),
_ => None,
};
if let Some(snapshot) = snapshot {
self.active_modal = Some(ActiveModal::CommandPalette {
entries: snapshot.entries,
state: snapshot.state,
window: crate::views::modal_window::ModalWindowState::new(),
});
} else {
self.active_modal = None;
}
InputOutcome::Changed
}
ArgPickerStep::Selected(item) => {
let chains_to_effort = matches!(command_clone.as_str(), "model" | "m")
&& item.insert_text.ends_with(char::is_whitespace);
if chains_to_effort {
let next_query = item.insert_text.clone();
if let Some(cmd) = self.prompt.slash_controller.registry().get(&command_clone) {
let ctx = self.prompt.slash_controller.app_ctx(&self.session.models);
if let Some(effort_items) = cmd.suggest_args(&ctx, &next_query)
&& Self::arg_items_look_like_effort_phase(&effort_items)
{
if let Some(ActiveModal::ArgPicker {
args_query,
items,
original_items,
state,
..
}) = self.active_modal.as_mut()
{
*args_query = next_query;
*items = effort_items.clone();
*original_items = effort_items;
// Effort sub-step is part of the type-to-find /model picker: open input-focused (cursor + type-to-filter), matching the rest of the flow.
*state = crate::views::picker::PickerState::input_active();
}
return InputOutcome::Changed;
}
}
}
let full = format!("/{} {}", command_clone, item.insert_text.trim_end());
self.active_modal = None;
InputOutcome::Action(Action::SendSlashCommandPreservingDraft(full))
}
}
}
/// Unified input handler for command palette and arg picker modals.
fn handle_palette_or_arg_input(&mut self, ev: &crossterm::event::Event) -> InputOutcome {
use crate::views::modal::{ActiveModal, PaletteCommand};
use crate::views::picker::{PickerConfig, PickerOutcome, handle_picker_input};
if matches!(self.active_modal, Some(ActiveModal::ArgPicker { .. })) {
return self.handle_arg_picker_input(ev);
}
let Some(ref mut modal) = self.active_modal else {
return InputOutcome::Changed;
};
match modal {
ActiveModal::CommandPalette {
entries: _, state, ..
} => {
let filtered = crate::views::modal::filter_palette_entries(&state.query);
let non_sel: Vec<bool> = filtered
.iter()
.map(|e| matches!(e.command, PaletteCommand::SectionHeader(_)))
.collect();
let entry_count = filtered.len();
let config = PickerConfig {
title: None,
show_search_hint: false,
expandable: false,
esc_clears_query: true,
shortcuts: Some(crate::views::picker::picker_shortcuts()),
pending_hint: None,
non_selectable: &non_sel,
non_selectable_clickable: &[],
shortcuts_area: None,
tabs: None,
active_tab: 0,
filter_label: None,
filter_key_hint: None,
filter_active: false,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first: crate::appearance::cache::load_vim_mode(),
};
match handle_picker_input(ev, state, entry_count, &config) {
PickerOutcome::Selected(i) => {
if i >= filtered.len() {
return InputOutcome::Changed;
}
if matches!(filtered[i].command, PaletteCommand::SectionHeader(_)) {
return InputOutcome::Changed;
}
let cmd = filtered[i].command.clone();
match cmd {
PaletteCommand::NewSession => {
self.active_modal = None;
InputOutcome::Action(Action::NewSession)
}
PaletteCommand::NewSessionInWorktree => {
self.active_modal = None;
InputOutcome::Action(Action::NewWorktreeSession {
load_session_id: None,
label: None,
git_ref: None,
})
}
PaletteCommand::Home => {
self.active_modal = None;
InputOutcome::Action(Action::ExitSessionConfirmed)
}
PaletteCommand::Quit => {
self.active_modal = None;
InputOutcome::Action(Action::QuitConfirmed)
}
PaletteCommand::HowTo => {
// Save palette state for Esc restore (same pattern as /resume).
let prev = {
let ActiveModal::CommandPalette { entries, state, .. } =
self.active_modal.as_ref().unwrap()
else {
unreachable!()
};
Some(crate::views::modal::PaletteSnapshot {
entries: entries.clone(),
state: state.clone(),
})
};
self.active_modal =
Some(crate::views::modal::howto_list_modal(prev));
InputOutcome::Changed
}
PaletteCommand::KeyboardShortcuts => {
use crate::views::shortcuts_help;
let reg = crate::actions::ActionRegistry::defaults();
let mut contexts = active_contexts_for_pane(self.active_pane);
// Same overlay-context push as the Ctrl+.
// path (`handle_agent_action`,
// `ActionId::ShortcutsHelp`).
if self.in_dashboard_overlay {
contexts.push(crate::actions::When::DashboardOverlay);
}
let entries =
shortcuts_help::build_entries(&contexts, &reg, self.vim_mode);
let state = shortcuts_help::build_initial_picker_state(&entries);
self.active_modal = Some(ActiveModal::ShortcutsHelp {
entries,
state,
window: Default::default(),
filter_active: false,
collapsed_sections:
crate::views::shortcuts_help::default_collapsed(),
expanded_ids: std::collections::HashSet::new(),
mode: crate::views::shortcuts_help::ShortcutsHelpMode::Browse,
});
InputOutcome::Changed
}
PaletteCommand::Memory => {
self.active_modal = None;
InputOutcome::Action(Action::OpenMemoryModal)
}
PaletteCommand::OpenExtensionsTab(tab) => {
self.active_modal = None;
InputOutcome::Action(Action::OpenExtensionsModal { tab })
}
PaletteCommand::OpenSettings => {
self.active_modal = None;
InputOutcome::Action(Action::OpenSettings)
}
PaletteCommand::OpenAgentsModal => {
self.active_modal = None;
InputOutcome::Action(Action::OpenConfigAgentsModal(None))
}
PaletteCommand::SlashCommand(text) => {
let trimmed = text
.trim_start_matches('/')
.trim_end_matches(' ')
.to_string();
if trimmed == "resume" {
let prev = {
let ActiveModal::CommandPalette { entries, state, .. } =
self.active_modal.as_ref().unwrap()
else {
unreachable!()
};
Some(crate::views::modal::PaletteSnapshot {
entries: entries.clone(),
state: state.clone(),
})
};
self.active_modal = Some(ActiveModal::SessionPicker {
state: crate::views::picker::PickerState::default(),
entries: None,
loading: true,
lanes: Default::default(),
previous_palette: prev,
window: crate::views::modal_window::ModalWindowState::new(),
content_results: None,
content_loading: false,
deep_search_seq: 0,
entries_query: None,
source_filter:
crate::views::session_picker::SourceFilter::default(),
pending_delete: None,
});
return InputOutcome::Action(Action::FetchSessionList);
}
let is_picker =
matches!(trimmed.as_str(), "model" | "m" | "theme" | "t");
if is_picker
&& let Some(command) =
self.prompt.slash_controller.registry().get(&trimmed)
{
let ctx =
self.prompt.slash_controller.app_ctx(&self.session.models);
if let Some(items) = command.suggest_args(&ctx, "")
&& !items.is_empty()
{
// Save palette state for Esc restore.
let prev = {
let ActiveModal::CommandPalette {
entries, state, ..
} = self.active_modal.as_ref().unwrap()
else {
unreachable!()
};
Some(crate::views::modal::PaletteSnapshot {
entries: entries.clone(),
state: state.clone(),
})
};
self.active_modal = Some(ActiveModal::ArgPicker {
command: trimmed,
args_query: String::new(),
items: items.clone(),
original_items: items,
// Type-to-find: open in input mode (vim: Esc→nav, i→input).
state: crate::views::picker::PickerState::input_active(
),
previous_palette: prev,
window:
crate::views::modal_window::ModalWindowState::new(),
});
return InputOutcome::Changed;
}
}
self.active_modal = None;
InputOutcome::Action(Action::SendSlashCommandPreservingDraft(text))
}
PaletteCommand::SectionHeader(_) => InputOutcome::Changed,
}
}
PickerOutcome::Closed => {
self.active_modal = None;
InputOutcome::Changed
}
PickerOutcome::Changed => {
// Re-filter entries based on updated query.
if let Some(ActiveModal::CommandPalette { entries, state, .. }) =
self.active_modal.as_mut()
{
*entries = crate::views::modal::filter_palette_entries(&state.query);
state.selected = state.selected.min(entries.len().saturating_sub(1));
}
InputOutcome::Changed
}
PickerOutcome::Unchanged => InputOutcome::Unchanged,
_ => InputOutcome::Changed,
}
}
ActiveModal::ArgPicker { .. } => unreachable!("routed via handle_arg_picker_input"),
ActiveModal::SessionPicker {
entries,
state,
loading: _,
previous_palette,
content_results,
content_loading,
entries_query,
source_filter,
pending_delete,
..
} => {
use crate::views::session_picker::{
CONTENT_EXPAND_OFFSET, PickerItem, build_entry_map, effective_filter_query,
};
let query_before = state.query.clone();
// Pin the current session's repo group using the live agent cwd.
let current_repo = crate::views::session_picker::repo_name_from_cwd(
&self.session.cwd.to_string_lossy(),
);
let entry_map = build_entry_map(
entries.as_deref(),
content_results.as_deref(),
effective_filter_query(&state.query, entries_query.as_deref()),
true,
*content_loading,
*source_filter,
Some(current_repo.as_str()),
);
let entry_count = entry_map.len();
let non_sel: Vec<bool> = entry_map.iter().map(|e| e.is_none()).collect();
let focused_is_foreign = match entry_map
.get(state.selected)
.and_then(|entry| entry.as_ref())
{
Some(PickerItem::Fuzzy { original_index }) => entries
.as_ref()
.and_then(|entries| entries.get(*original_index))
.is_some_and(|entry| {
crate::app::foreign_sessions::is_foreign_picker_source(&entry.source)
}),
_ => false,
};
// Chat-mode picker lists conversations only: the Local/Remote
// source filter and local-disk delete are dead weight there.
let chat_mode = self.app_chat_mode;
let config = PickerConfig {
title: Some("Resume session"),
show_search_hint: true,
expandable: true,
// Esc returns to palette or closes.
esc_clears_query: false,
shortcuts: Some(crate::views::picker::picker_shortcuts()),
pending_hint: None,
non_selectable: &non_sel,
non_selectable_clickable: &[],
shortcuts_area: None,
tabs: None,
active_tab: 0,
filter_label: (!chat_mode).then(|| source_filter.label()),
filter_key_hint: (!chat_mode).then_some("f"),
filter_active: !chat_mode && source_filter.is_active(),
action_keys: if chat_mode || focused_is_foreign {
&[]
} else {
&[('d', "delete")]
},
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first: crate::appearance::cache::load_vim_mode(),
};
// Delete-confirmation flow: `d` arms a confirmation on the
// focused row, then `y` confirms and `n` (or any other key)
// cancels. y/n are intercepted here — before the picker
// handler — only while armed, so the rest of the time `y`
// keeps its normal meaning (copy the session id).
if pending_delete.is_some()
&& let crossterm::event::Event::Key(k) = ev
&& k.kind == KeyEventKind::Press
&& k.modifiers.is_empty()
{
match k.code {
crossterm::event::KeyCode::Char('y') => {
// Confirm. The cwd was captured when the row was
// armed, so this can't be foiled by an async
// picker-list update (e.g. a deep-search result)
// landing between `d` and `y`.
if let Some((source, session_id, cwd)) = pending_delete.take() {
return InputOutcome::Action(Action::DeleteSession {
source,
session_id,
cwd,
});
}
return InputOutcome::Changed;
}
crossterm::event::KeyCode::Char('n') => {
*pending_delete = None;
return InputOutcome::Changed;
}
_ => {
// Any other key cancels, then falls through to its
// normal handling below.
*pending_delete = None;
}
}
}
match handle_picker_input(ev, state, entry_count, &config) {
PickerOutcome::Selected(i) => {
match entry_map.get(i).and_then(|e| e.as_ref()) {
Some(PickerItem::Fuzzy { original_index }) => {
// Don't clear active_modal here — dispatch_pick_session
// reads entries from it before clearing.
InputOutcome::Action(Action::PickSession(*original_index))
}
Some(PickerItem::Content { hit_index }) => {
if let Some(hits) = content_results.as_ref()
&& let Some(hit) = hits.get(*hit_index)
{
InputOutcome::Action(Action::PickContentSession {
session_id: hit.session_id.clone(),
cwd: hit.cwd.clone(),
})
} else {
InputOutcome::Changed
}
}
None => InputOutcome::Changed,
}
}
PickerOutcome::SubmitQuery => {
let query = state.query.trim().to_string();
if !query.is_empty() {
self.active_modal = None;
InputOutcome::Action(Action::LoadSession(query, None, false))
} else {
InputOutcome::Unchanged
}
}
PickerOutcome::Closed => {
if let Some(snapshot) = previous_palette.take() {
self.active_modal = Some(ActiveModal::CommandPalette {
entries: snapshot.entries,
state: snapshot.state,
window: crate::views::modal_window::ModalWindowState::new(),
});
} else {
self.active_modal = None;
}
// A search/list fetch may still be in flight; the
// dispatch layer must invalidate it now that the
// modal (its landing surface) is gone.
InputOutcome::Action(Action::SessionPickerClosed)
}
PickerOutcome::Expand(i) => match entry_map.get(i).and_then(|e| e.as_ref()) {
Some(PickerItem::Fuzzy { original_index }) => {
if let Some(ents) = entries.as_ref()
&& let Some(entry) = ents.get(*original_index)
&& !crate::app::foreign_sessions::is_foreign_picker_source(
&entry.source,
)
&& !state.expanded.contains(original_index)
{
InputOutcome::Action(Action::ExpandSessionCard {
source: entry.source.clone(),
session_id: entry.id.clone(),
})
} else {
InputOutcome::Changed
}
}
Some(PickerItem::Content { hit_index }) => {
if let Some(hits) = content_results.as_ref()
&& let Some(hit) = hits.get(*hit_index)
{
InputOutcome::Action(Action::ExpandSessionCard {
source: "local".into(),
session_id: hit.session_id.clone(),
})
} else {
InputOutcome::Changed
}
}
None => InputOutcome::Changed,
},
PickerOutcome::Collapse(i) => match entry_map.get(i).and_then(|e| e.as_ref()) {
Some(PickerItem::Fuzzy { original_index }) => {
if let Some(ents) = entries.as_ref()
&& let Some(entry) = ents.get(*original_index)
&& state.expanded.contains(original_index)
{
InputOutcome::Action(Action::ExpandSessionCard {
source: entry.source.clone(),
session_id: entry.id.clone(),
})
} else {
InputOutcome::Changed
}
}
Some(PickerItem::Content { hit_index }) => {
let key = CONTENT_EXPAND_OFFSET + hit_index;
if state.expanded.contains(&key)
&& let Some(hits) = content_results.as_ref()
&& let Some(hit) = hits.get(*hit_index)
{
InputOutcome::Action(Action::ExpandSessionCard {
source: "local".into(),
session_id: hit.session_id.clone(),
})
} else {
InputOutcome::Changed
}
}
None => InputOutcome::Changed,
},
PickerOutcome::Copy(i) => {
if let Some(Some(PickerItem::Fuzzy { original_index })) = entry_map.get(i) {
InputOutcome::Action(Action::CopySessionId(*original_index))
} else {
InputOutcome::Changed
}
}
PickerOutcome::Changed => {
if state.query != query_before {
return InputOutcome::Action(Action::TriggerDeepSearch);
}
InputOutcome::Changed
}
PickerOutcome::Unchanged => {
if let crossterm::event::Event::Key(key) = ev
&& key.kind == KeyEventKind::Press
{
if crate::key!('w', CONTROL).matches(key) && entry_count > 0 {
match entry_map.get(state.selected).and_then(|e| e.as_ref()) {
Some(PickerItem::Fuzzy { original_index }) => {
if let Some(entries) = entries.as_ref()
&& let Some(entry) = entries.get(*original_index)
&& !crate::app::foreign_sessions::is_foreign_picker_source(
&entry.source,
)
{
return InputOutcome::Action(
Action::PickSessionInWorktree(*original_index),
);
}
}
Some(PickerItem::Content { hit_index }) => {
if let Some(hits) = content_results.as_ref()
&& let Some(hit) = hits.get(*hit_index)
{
return InputOutcome::Action(
Action::PickContentSessionInWorktree {
session_id: hit.session_id.clone(),
cwd: hit.cwd.clone(),
},
);
}
}
None => {}
}
}
if crate::key!('/', CONTROL).matches(key)
&& !state.query.trim().is_empty()
{
return InputOutcome::Action(Action::ForceDeepSearch);
}
}
InputOutcome::Unchanged
}
PickerOutcome::FilterCycled => {
InputOutcome::Action(Action::CycleSessionSourceFilter)
}
PickerOutcome::Action('d') => {
// Arm a delete confirmation on the highlighted row,
// capturing source, id, and cwd now (the row is present at
// this moment) so the `y` confirm can't be foiled by an
// async picker-list update. `y` confirms / `n` cancels
// on the next key press (intercepted above).
*pending_delete =
match entry_map.get(state.selected).and_then(|e| e.as_ref()) {
Some(PickerItem::Fuzzy { original_index }) => entries
.as_ref()
.and_then(|e| e.get(*original_index))
.filter(|entry| {
!crate::app::foreign_sessions::is_foreign_picker_source(
&entry.source,
)
})
.map(|e| (e.source.clone(), e.id.clone(), e.cwd.clone())),
Some(PickerItem::Content { hit_index }) => content_results
.as_ref()
.and_then(|h| h.get(*hit_index))
.map(|h| ("local".into(), h.session_id.clone(), h.cwd.clone())),
None => None,
};
InputOutcome::Changed
}
PickerOutcome::NonSelectableClick(_)
| PickerOutcome::TabChanged(_)
| PickerOutcome::Action(_) => InputOutcome::Changed,
}
}
_ => InputOutcome::Changed,
}
}
/// Basic input handler for documentation modals (DocPicker list / DocViewer panel).
fn handle_doc_input(&mut self, ev: &crossterm::event::Event) -> InputOutcome {
use crate::views::modal::ActiveModal;
use crate::views::picker::{
PickerConfig, PickerEntry, PickerOutcome, PickerRow, handle_picker_input,
};
// DocPicker list: use unified picker for nav/select
if let Some(ActiveModal::DocPicker {
entries,
state,
previous_palette,
..
}) = &mut self.active_modal
{
let filtered: Vec<_> = if state.query.is_empty() {
entries.iter().enumerate().collect()
} else {
let q = state.query.to_lowercase();
entries
.iter()
.enumerate()
.filter(|(_, e)| {
e.title.to_lowercase().contains(&q)
|| e.description.to_lowercase().contains(&q)
})
.collect()
};
let entry_count = filtered.len();
let non_sel: Vec<bool> = vec![false; entry_count];
let _picker_entries: Vec<PickerEntry> = filtered
.iter()
.map(|(i, e)| {
PickerEntry::Row(PickerRow {
label: &e.title,
right_label: &e.description,
selected: filtered
.get(state.selected)
.map(|(o, _)| *o)
.unwrap_or(usize::MAX)
== *i,
expanded: false,
dimmed: false,
indent: 0,
badge: "",
badge_color: None,
collapsible: false,
fields: &[],
description_lines: &[],
summary_lines: &[],
})
})
.collect();
let config = PickerConfig {
title: Some("How-to Guides"),
show_search_hint: false,
expandable: false,
esc_clears_query: true,
shortcuts: Some(crate::views::picker::picker_shortcuts()),
pending_hint: None,
non_selectable: &non_sel,
non_selectable_clickable: &[],
shortcuts_area: None,
tabs: None,
active_tab: 0,
filter_label: None,
filter_key_hint: None,
filter_active: false,
action_keys: &[],
disable_search: false,
compact_bottom_bar: false,
search_only_on_slash: false,
vim_normal_first: crate::appearance::cache::load_vim_mode(),
};
match handle_picker_input(ev, state, entry_count, &config) {
PickerOutcome::Selected(i) => {
if let Some((orig_idx, _)) = filtered.get(i)
&& let Some(doc) = entries.get(*orig_idx)
{
let title = doc.title.clone();
let content = doc.content.to_string();
// Shuttle the palette snapshot through DocViewer so it can
// be passed back to DocPicker when the user presses Esc.
let prev = previous_palette.take();
self.active_modal = Some(ActiveModal::DocViewer {
title,
content,
scroll: 0,
window: crate::views::modal_window::ModalWindowState::new(),
cached_lines: None,
previous_palette: prev,
standalone: false,
});
}
InputOutcome::Changed
}
PickerOutcome::Closed => {
// Restore the command palette if we have a saved snapshot
// (same pattern as ArgPicker / SessionPicker).
if let Some(snapshot) = previous_palette.take() {
self.active_modal = Some(ActiveModal::CommandPalette {
entries: snapshot.entries,
state: snapshot.state,
window: crate::views::modal_window::ModalWindowState::new(),
});
} else {
self.active_modal = None;
}
InputOutcome::Changed
}
_ => InputOutcome::Changed,
}
} else if let Some(ActiveModal::DocViewer { scroll, .. }) = &mut self.active_modal {
if let Event::Key(KeyEvent { code, .. }) = ev
&& modal::apply_doc_scroll(*code, scroll)
{
return InputOutcome::Changed;
}
InputOutcome::Changed
} else {
InputOutcome::Changed
}
}
/// Handle mouse events while a modal is active.
///
/// Click on a button → same as pressing that key.
/// Hover → update `modal_hovered_key` for highlight.
pub(super) fn handle_modal_mouse(
&mut self,
mouse: &crossterm::event::MouseEvent,
) -> InputOutcome {
use crate::views::modal::ActiveModal;
use crate::views::modal_window::{self as mw, ModalWindowOutcome};
use crossterm::event::MouseEventKind;
// Picker-based modals: route through ModalWindow chrome first,
// then delegate content events to the picker input handler.
if matches!(
self.active_modal,
Some(
ActiveModal::CommandPalette { .. }
| ActiveModal::ArgPicker { .. }
| ActiveModal::SessionPicker { .. }
| ActiveModal::DocPicker { .. }
| ActiveModal::DocViewer { .. }
| ActiveModal::ShortcutsHelp { .. }
| ActiveModal::RememberNoteReview { .. }
)
) {
let window = match self.active_modal.as_mut() {
Some(ActiveModal::CommandPalette { window, .. }) => window,
Some(ActiveModal::ArgPicker { window, .. }) => window,
Some(ActiveModal::SessionPicker { window, .. }) => window,
Some(ActiveModal::DocPicker { window, .. }) => window,
Some(ActiveModal::DocViewer { window, .. }) => window,
Some(ActiveModal::ShortcutsHelp { window, .. }) => window,
Some(ActiveModal::RememberNoteReview { window, .. }) => window,
_ => unreachable!(),
};
let outcome = mw::handle_modal_mouse(window, mouse.kind, mouse.column, mouse.row);
match outcome {
ModalWindowOutcome::CloseRequested => {
// Match keyboard Esc: step back from model effort phase
// before fully dismissing the ArgPicker.
if self.try_arg_picker_step_back_from_effort() {
return InputOutcome::Changed;
}
// Match keyboard Esc: a closed SessionPicker may still
// have a list/search fetch in flight — the dispatch
// layer must invalidate it (its landing surface is gone).
let closed_session_picker =
matches!(self.active_modal, Some(ActiveModal::SessionPicker { .. }));
// Single take() handles all modal types to avoid the
// double-take bug where the first consume drops the value
// before the second branch can match.
match self.active_modal.take() {
Some(ActiveModal::DocViewer {
previous_palette,
standalone,
..
}) => {
if !standalone {
self.active_modal =
Some(crate::views::modal::howto_list_modal(previous_palette));
}
}
Some(
ActiveModal::ArgPicker {
previous_palette: Some(snap),
..
}
| ActiveModal::SessionPicker {
previous_palette: Some(snap),
..
}
| ActiveModal::DocPicker {
previous_palette: Some(snap),
..
},
) => {
self.active_modal = Some(ActiveModal::CommandPalette {
entries: snap.entries,
state: snap.state,
window: crate::views::modal_window::ModalWindowState::new(),
});
}
_ => {
// No snapshot — close entirely (take() already set to None).
}
}
if closed_session_picker {
return InputOutcome::Action(Action::SessionPickerClosed);
}
return InputOutcome::Changed;
}
ModalWindowOutcome::Handled => {
return InputOutcome::Changed;
}
ModalWindowOutcome::Unhandled => {
// DocViewer / RememberNoteReview: wheel scrolls the markdown body.
if let Some(
ActiveModal::DocViewer { scroll, .. }
| ActiveModal::RememberNoteReview { scroll, .. },
) = self.active_modal.as_mut()
{
if modal::apply_doc_mouse_scroll(mouse.kind, scroll) {
return InputOutcome::Changed;
}
return InputOutcome::Changed;
}
// Content area events — delegate to picker input.
if matches!(self.active_modal, Some(ActiveModal::DocPicker { .. })) {
let ev = crossterm::event::Event::Mouse(*mouse);
return self.handle_doc_input(&ev);
}
if let Some(ActiveModal::ShortcutsHelp {
entries,
state,
filter_active,
collapsed_sections,
expanded_ids,
mode,
..
}) = &mut self.active_modal
{
use crate::views::shortcuts_help::{self, ShortcutsHelpOutcome};
return match shortcuts_help::handle_mouse(
mouse,
entries,
state,
*filter_active,
collapsed_sections,
mode,
) {
ShortcutsHelpOutcome::Close => {
self.active_modal = None;
InputOutcome::Changed
}
ShortcutsHelpOutcome::ToggleFilter => {
*filter_active = !*filter_active;
state.selected = 0;
InputOutcome::Changed
}
ShortcutsHelpOutcome::ToggleSection(idx) => {
shortcuts_help::toggle_membership(collapsed_sections, idx);
InputOutcome::Changed
}
// Unreachable today: handle_mouse never yields ToggleExpand (a row click opens detail); kept for exhaustiveness.
ShortcutsHelpOutcome::ToggleExpand(action_id) => {
shortcuts_help::toggle_membership(expanded_ids, action_id);
InputOutcome::Changed
}
ShortcutsHelpOutcome::Changed => InputOutcome::Changed,
ShortcutsHelpOutcome::Unchanged => InputOutcome::Unchanged,
};
}
let ev = crossterm::event::Event::Mouse(*mouse);
return self.handle_palette_or_arg_input(&ev);
}
_ => return InputOutcome::Changed,
}
}
// MemoryBrowser: route through ModalWindow chrome, then delegate.
if let Some(ActiveModal::MemoryBrowser { state }) = &mut self.active_modal {
let outcome =
mw::handle_modal_mouse(&mut state.window, mouse.kind, mouse.column, mouse.row);
match outcome {
ModalWindowOutcome::CloseRequested => {
self.active_modal = None;
return InputOutcome::Changed;
}
ModalWindowOutcome::Handled => return InputOutcome::Changed,
ModalWindowOutcome::Unhandled => {
return crate::views::memory_modal::handle_memory_mouse(
state,
mouse.kind,
mouse.column,
mouse.row,
);
}
_ => return InputOutcome::Changed,
}
}
// Settings: route through ModalWindow chrome, then delegate.
if let Some(ActiveModal::Settings { state }) = &mut self.active_modal {
let outcome =
mw::handle_modal_mouse(&mut state.window, mouse.kind, mouse.column, mouse.row);
match outcome {
ModalWindowOutcome::CloseRequested => {
self.active_modal = None;
return InputOutcome::Changed;
}
ModalWindowOutcome::Handled => {
if matches!(mouse.kind, MouseEventKind::Moved) {
state.hover_row = None;
}
return InputOutcome::Changed;
}
ModalWindowOutcome::Unhandled => {
let out = crate::views::settings_modal::handle_settings_mouse(
state,
mouse.kind,
mouse.column,
mouse.row,
);
return apply_settings_outcome(self, out);
}
_ => return InputOutcome::Changed,
}
}
// ResetSettingsConfirm: route mouse events through the
// modal-window chrome.
if let Some(ActiveModal::ResetSettingsConfirm { settings_state, .. }) =
&mut self.active_modal
{
let outcome = mw::handle_modal_mouse(
&mut settings_state.window,
mouse.kind,
mouse.column,
mouse.row,
);
return match outcome {
ModalWindowOutcome::CloseRequested => {
// Close-button (X) click → Cancel. Mirrors Esc /
// F2 / Ctrl+, keyboard semantics.
InputOutcome::Action(Action::ConfirmResetSetting {
choice: crate::views::modal::ResetSettingsResult::Cancel,
})
}
ModalWindowOutcome::ShortcutActivated(id) => {
use crate::views::modal::{
RESET_CONFIRM_NO_ID, RESET_CONFIRM_YES_ID, ResetSettingsResult,
};
let choice = if id == RESET_CONFIRM_YES_ID {
ResetSettingsResult::Reset
} else if id == RESET_CONFIRM_NO_ID {
ResetSettingsResult::Cancel
} else {
return InputOutcome::Changed;
};
InputOutcome::Action(Action::ConfirmResetSetting { choice })
}
ModalWindowOutcome::Handled => InputOutcome::Changed,
_ => InputOutcome::Unchanged,
};
}
// Standard modal mouse handling (EditConfirm).
match mouse.kind {
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
for btn in &self.modal_buttons {
if btn.rect.contains((mouse.column, mouse.row).into()) {
let key = KeyEvent::new(KeyCode::Char(btn.key), KeyModifiers::NONE);
return self.handle_modal_key(&key);
}
}
InputOutcome::Changed
}
MouseEventKind::Moved => {
let new_hover = self
.modal_buttons
.iter()
.find(|btn| btn.rect.contains((mouse.column, mouse.row).into()))
.map(|btn| btn.key);
if new_hover != self.modal_hovered_key {
self.modal_hovered_key = new_hover;
InputOutcome::Changed
} else {
InputOutcome::Unchanged
}
}
_ => InputOutcome::Changed,
}
}
/// Draw the active modal overlay: the per-`ActiveModal`-variant render
/// dispatch, called from `draw` which early-returns afterwards.
///
/// `pub(crate)` so minimal mode's overlay host can reuse the exact same
/// centered-popup rendering (hosting the command palette / shortcuts help /
/// settings / pickers in its grown live viewport — see
/// `crate::minimal::overlay::render_app_modal`).
// Allow inherited from `draw`: covers the nested picker render helpers.
#[allow(clippy::too_many_arguments)]
pub(crate) fn draw_active_modal(
&mut self,
area: Rect,
buf: &mut Buffer,
theme: Theme,
compact: bool,
) {
if let Some(ref mut active_modal) = self.active_modal {
use crate::views::modal_window::{
self as mw, ModalSizing, ModalWindowConfig, Shortcut,
};
use crate::views::picker::{self, PickerEntry, PickerRow};
// Standard footer shortcuts for picker-style modals.
let mut picker_shortcuts: Vec<Shortcut> = vec![
Shortcut {
label: "\u{2191}/\u{2193} nav",
clickable: false,
id: 0,
},
Shortcut {
label: "Enter select",
clickable: false,
id: 0,
},
Shortcut {
label: "Esc close",
clickable: false,
id: 0,
},
];
// EditConfirm has no draw arm: the dirty pane-switch lock blocks
// that flow instead, so arming EditConfirm here would capture all
// input with nothing rendered to show it.
if let modal::ActiveModal::CommandPalette {
entries: _,
state,
window,
} = active_modal
{
// Command palette: ModalWindow chrome + picker content.
let filtered = modal::filter_palette_entries(&state.query);
let non_sel: Vec<bool> = filtered
.iter()
.map(|e| matches!(e.command, modal::PaletteCommand::SectionHeader(_)))
.collect();
let picker_entries: Vec<PickerEntry> = filtered
.iter()
.enumerate()
.map(|(i, e)| {
if matches!(e.command, modal::PaletteCommand::SectionHeader(_)) {
PickerEntry::Header { label: &e.label }
} else {
PickerEntry::Row(PickerRow {
label: &e.label,
right_label: &e.shortcut,
selected: state.hovered == Some(i)
|| (state.hovered.is_none() && i == state.selected),
expanded: false,
fields: &[],
description_lines: &[],
summary_lines: &[],
dimmed: false,
indent: 0,
badge: "",
badge_color: None,
collapsible: false,
})
}
})
.collect();
let compact = self.scrollback.appearance().prompt.compact;
mw::push_vim_nav_search_hint(&mut picker_shortcuts, state.search_active);
let modal_config = ModalWindowConfig {
title: "Commands",
tabs: None,
shortcuts: &picker_shortcuts,
sizing: ModalSizing {
width_pct: 0.50,
max_width: 80,
min_width: 44,
v_margin: 4,
h_pad: 2,
v_pad: 1,
footer_lines: 2,
}
.with_compact(compact),
fold_info: None,
};
if let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, &theme)
{
picker::render_picker_in_modal(
buf,
mca.content,
mca.inner_x,
mca.inner_width,
&theme,
state,
&picker_entries,
&non_sel,
false,
);
}
} else if let modal::ActiveModal::ArgPicker {
command,
args_query,
items,
state,
window,
..
} = active_modal
{
// Arg picker: ModalWindow chrome + picker content.
let title = match command.as_str() {
"model" | "m" if !args_query.is_empty() => "Pick reasoning effort",
"model" | "m" => "Pick model",
"theme" | "t" => "Pick theme",
_ => "Pick option",
};
let picker_entries: Vec<PickerEntry> = items
.iter()
.enumerate()
.map(|(i, item)| {
PickerEntry::Row(PickerRow {
label: &item.display,
right_label: &item.description,
selected: state.hovered == Some(i)
|| (state.hovered.is_none() && i == state.selected),
expanded: false,
fields: &[],
description_lines: &[],
summary_lines: &[],
dimmed: false,
indent: 0,
badge: "",
badge_color: None,
collapsible: false,
})
})
.collect();
let compact = self.scrollback.appearance().prompt.compact;
mw::push_vim_nav_search_hint(&mut picker_shortcuts, state.search_active);
let modal_config = ModalWindowConfig {
title,
tabs: None,
shortcuts: &picker_shortcuts,
sizing: ModalSizing {
width_pct: 0.50,
max_width: 80,
min_width: 44,
v_margin: 4,
h_pad: 2,
v_pad: 1,
footer_lines: 2,
}
.with_compact(compact),
fold_info: None,
};
if let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, &theme)
{
picker::render_picker_in_modal(
buf,
mca.content,
mca.inner_x,
mca.inner_width,
&theme,
state,
&picker_entries,
&[],
false,
);
}
} else if let modal::ActiveModal::SessionPicker {
entries,
state,
loading,
lanes,
window,
content_results,
content_loading,
entries_query,
source_filter,
pending_delete,
..
} = active_modal
{
// Session picker: ModalWindow chrome + picker content.
use crate::app::app_view::filter_session_entries;
use crate::views::picker::PickerField;
use crate::views::session_picker::{
build_content_entry_data, build_content_header_label,
};
// While a delete confirmation is armed, the footer swaps to a
// "y confirm / n cancel" prompt. Otherwise show the normal
// hints plus the `d delete` action. Chat mode drops the
// deep-search / filter / delete hints (local-disk-row actions).
let chat_mode = self.app_chat_mode;
let mut session_shortcuts: Vec<Shortcut> = if pending_delete.is_some() {
vec![
Shortcut {
label: "y confirm delete",
clickable: false,
id: 0,
},
Shortcut {
label: "n cancel",
clickable: false,
id: 0,
},
]
} else {
let external =
*source_filter == crate::views::session_picker::SourceFilter::External;
let mut shortcuts = vec![Shortcut {
label: "\u{2191}\u{2193} nav",
clickable: false,
id: 0,
}];
if !external {
shortcuts.extend([
Shortcut {
label: "e expand",
clickable: false,
id: 0,
},
Shortcut {
label: "/ search",
clickable: false,
id: 0,
},
]);
}
if !chat_mode {
shortcuts.push(Shortcut {
label: "f filter",
clickable: false,
id: 0,
});
if !external {
shortcuts.push(Shortcut {
label: "d delete",
clickable: false,
id: 0,
});
}
}
shortcuts
};
if pending_delete.is_none() {
mw::push_vim_nav_search_hint(&mut session_shortcuts, state.search_active);
}
let compact = self.scrollback.appearance().prompt.compact;
let modal_config = ModalWindowConfig {
title: "Resume session",
tabs: None,
shortcuts: &session_shortcuts,
sizing: ModalSizing {
width_pct: 0.65,
max_width: 120,
min_width: 48,
v_margin: 4,
h_pad: 2,
v_pad: 1,
footer_lines: 2,
}
.with_compact(compact),
fold_info: None,
};
if let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, &theme)
{
let content_area = mca.content;
picker::render_search_bar(
buf,
content_area.x,
content_area.y,
content_area.width,
&theme,
&state.query,
state.search_active,
true,
state.query_cursor,
Some(theme.bg_base),
);
// Render filter indicator on the search bar row (hidden in
// chat mode — every row is a conversation).
if chat_mode {
state.filter_area = None;
} else {
let filter_rect = picker::render_filter_indicator(
buf,
content_area.x,
content_area.y,
content_area.width,
&theme,
source_filter.label(),
"f",
source_filter.is_active(),
state.filter_hovered,
);
state.filter_area = Some(filter_rect);
}
let sep_y = content_area.y + 1;
if sep_y < content_area.y + content_area.height {
picker::render_divider(
buf,
mca.inner_x,
sep_y,
mca.inner_width,
&theme,
Some(theme.bg_base),
);
}
let entries_start_y = sep_y + 1;
let search_bar_rect =
Rect::new(content_area.x, content_area.y, content_area.width, 1);
// Build session picker entries (shared helper). The same
// effective query must drive filtering AND the content
// header/rows gates below, or this render disagrees with
// the input handler's `build_entry_map` (which receives
// the effective query) on row indices.
let filter_query = crate::views::session_picker::effective_filter_query(
&state.query,
entries_query.as_deref(),
);
let entries_data = entries.as_deref().unwrap_or(&[]);
let filtered_indices =
filter_session_entries(entries.as_deref(), filter_query, *source_filter);
let built = crate::views::session_picker::build_session_entry_data(
entries_data,
&filtered_indices,
state,
content_area.width,
);
let fields_vecs: Vec<Vec<PickerField>> = built
.iter()
.map(|b| {
b.field_data
.iter()
.map(|(l, v)| PickerField { label: l, value: v })
.collect()
})
.collect();
let current_repo = crate::views::session_picker::repo_name_from_cwd(
&self.session.cwd.to_string_lossy(),
);
let (mut picker_entries, mut non_sel_flags) =
crate::views::session_picker::build_grouped_picker_entries(
entries_data,
&filtered_indices,
&built,
&fields_vecs,
state,
Some(current_repo.as_str()),
);
// Append content search result rows (same pattern as welcome).
let content_start = picker_entries.len() + 1;
let content_entry_data = if let Some(hits) = content_results.as_deref()
&& *source_filter != crate::views::session_picker::SourceFilter::External
&& !filter_query.is_empty()
{
build_content_entry_data(
hits,
entries_data,
&filtered_indices,
state,
content_start,
)
} else {
Vec::new()
};
let has_content_rows = !content_entry_data.is_empty();
let effective_content_loading = *content_loading
&& *source_filter != crate::views::session_picker::SourceFilter::External;
let spinner_label = build_content_header_label(
effective_content_loading,
has_content_rows,
self.scrollback.tick_count(),
);
let show_content_header = has_content_rows
|| (effective_content_loading && !filter_query.trim().is_empty());
if show_content_header {
picker_entries.push(PickerEntry::Header {
label: &spinner_label,
});
non_sel_flags.push(true);
}
let content_fields: Vec<Vec<PickerField>> = content_entry_data
.iter()
.map(|b| {
b.field_data
.iter()
.map(|(l, v)| PickerField { label: l, value: v })
.collect()
})
.collect();
let content_snippets: Vec<[&str; 1]> = content_entry_data
.iter()
.map(|b| [b.snippet_preview.as_deref().unwrap_or("")])
.collect();
for (i, (b, fields)) in content_entry_data
.iter()
.zip(content_fields.iter())
.enumerate()
{
let has_snippet = b.snippet_preview.is_some();
picker_entries.push(PickerEntry::Row(PickerRow {
label: &b.summary,
right_label: &b.right_text,
selected: b.is_selected,
expanded: b.is_expanded,
fields,
description_lines: if has_snippet {
&content_snippets[i]
} else {
&[]
},
summary_lines: &[],
dimmed: false,
indent: 1,
badge: if has_snippet { "match" } else { "" },
badge_color: Some(theme.accent_user),
collapsible: true,
}));
non_sel_flags.push(false);
}
let entries_area = Rect {
x: content_area.x,
y: entries_start_y,
width: content_area.width,
height: content_area
.height
.saturating_sub(entries_start_y.saturating_sub(content_area.y)),
};
let content_hit = picker::render_picker_content_with_scrollbar_x(
buf,
entries_area,
&theme,
state,
&picker_entries,
&non_sel_flags,
&[],
Some(theme.bg_base),
entries.is_none() && (*loading || lanes.foreign_loading),
mca.inner_x + mca.inner_width - 1,
);
state.hit_areas = Some(picker::PickerHitAreas {
close_button: Rect::default(),
search_bar: search_bar_rect,
item_rects: content_hit.item_rects,
entry_indices: content_hit.entry_indices,
tab_rects: vec![],
filter_rect: None,
});
}
} else if let modal::ActiveModal::DocPicker {
entries,
state,
window,
..
} = active_modal
{
let compact = self.scrollback.appearance().prompt.compact;
modal::render_doc_picker_overlay(
buf, area, window, entries, state, compact, &theme,
);
} else if let modal::ActiveModal::DocViewer {
title,
content,
scroll,
window,
cached_lines,
..
} = active_modal
{
let compact = self.scrollback.appearance().prompt.compact;
modal::render_doc_viewer_overlay(
buf,
area,
window,
title,
content,
scroll,
cached_lines,
compact,
&theme,
);
} else if let modal::ActiveModal::RememberNoteReview {
ref raw_content,
ref enhanced_content,
showing_enhanced,
ref mut scroll,
ref mut window,
ref mut cached_lines,
..
} = *active_modal
{
use crate::views::modal_window::{self as mw, Shortcut};
let has_enhanced = enhanced_content.is_some();
let tab_label = if showing_enhanced {
"Tab raw"
} else if has_enhanced {
"Tab enhanced"
} else {
"enhancing\u{2026}"
};
let shortcuts: Vec<Shortcut> = vec![
Shortcut {
label: "\u{2191}/\u{2193} scroll",
clickable: false,
id: 0,
},
Shortcut {
label: "Enter save",
clickable: false,
id: 0,
},
Shortcut {
label: tab_label,
clickable: false,
id: 0,
},
Shortcut {
label: "Esc cancel",
clickable: false,
id: 0,
},
];
let compact = self.scrollback.appearance().prompt.compact;
let modal_config = mw::ModalWindowConfig {
title: "Memory Note",
tabs: None,
shortcuts: &shortcuts,
sizing: mw::ModalSizing {
width_pct: 0.65,
max_width: 100,
min_width: 40,
v_margin: 4,
h_pad: 2,
v_pad: 1,
footer_lines: 2,
}
.with_compact(compact),
fold_info: None,
};
if let Some(mw::ModalContentArea {
content: content_area,
..
}) = mw::render_modal_window(buf, area, window, &modal_config, &theme)
{
let display_content = if showing_enhanced {
enhanced_content.as_deref().unwrap_or(raw_content)
} else {
raw_content
};
let w = content_area.width;
let needs_reparse = cached_lines
.as_ref()
.is_none_or(|(cached_w, _)| *cached_w != w);
if needs_reparse {
let mc = crate::scrollback::blocks::markdown_content::MarkdownContent::new(
display_content.to_string(),
);
let output = mc.output(w as usize);
let lines: Vec<ratatui::text::Line<'static>> =
output.lines.into_iter().map(|b| b.content).collect();
*cached_lines = Some((w, lines));
}
let all_lines = &cached_lines.as_ref().unwrap().1;
let max_scroll = all_lines.len().saturating_sub(content_area.height as usize);
*scroll = (*scroll as usize).min(max_scroll) as u16;
let start = *scroll as usize;
let visible: Vec<Line> = all_lines
.iter()
.skip(start)
.take(content_area.height as usize)
.cloned()
.collect();
let para = ratatui::widgets::Paragraph::new(visible)
.wrap(ratatui::widgets::Wrap { trim: false });
para.render(content_area, buf);
}
} else if let modal::ActiveModal::ShortcutsHelp {
entries,
state,
window,
filter_active,
collapsed_sections,
expanded_ids,
mode,
} = active_modal
{
use crate::views::shortcuts_help;
// Detail screen reuses the same modal chrome with a different footer (pattern B).
if mode.is_detail() {
shortcuts_help::render_detail(buf, area, window, mode, &theme, compact);
return;
}
let rows = shortcuts_help::CheatsheetRows::build(
entries,
&state.query,
*filter_active,
collapsed_sections,
);
let help_refs = rows.help_refs();
let picker_entries = rows.picker_entries(state, expanded_ids, &help_refs);
let non_sel: Vec<bool> = vec![false; picker_entries.len()];
let footer = shortcuts_help::modal_footer(*filter_active);
let modal_config = mw::ModalWindowConfig {
title: "Keyboard Shortcuts",
tabs: None,
shortcuts: &footer,
sizing: shortcuts_help::modal_sizing(compact),
fold_info: None,
};
if let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, &theme)
{
let searching = state.search_active || !state.query.is_empty();
picker::render_picker_in_modal_inner(
buf,
mca.content,
mca.inner_x,
mca.inner_width,
&theme,
state,
&picker_entries,
&non_sel,
false,
searching,
!searching,
);
}
} else if let modal::ActiveModal::MemoryBrowser { state: mem_state } = active_modal {
crate::views::memory_modal::render_memory_modal(buf, area, mem_state, compact);
} else if let modal::ActiveModal::Settings {
state: settings_state,
} = active_modal
{
crate::views::settings_modal::render_settings_modal(
buf,
area,
settings_state,
compact,
None,
);
} else if matches!(
active_modal,
modal::ActiveModal::ResetSettingsConfirm { .. }
) {
// Render settings modal with reset-confirm overlay.
let prompt = crate::views::modal::reset_confirm_prompt(active_modal)
.unwrap_or_else(|| "Reset setting to default?".to_owned());
let breadcrumb = crate::views::modal::reset_confirm_breadcrumb(active_modal)
.unwrap_or_else(|| "Reset setting".to_owned());
if let modal::ActiveModal::ResetSettingsConfirm { settings_state, .. } =
active_modal
{
let overlay = crate::views::settings_modal::ResetConfirmOverlay {
prompt: &prompt,
breadcrumb_suffix: &breadcrumb,
};
crate::views::settings_modal::render_settings_modal(
buf,
area,
settings_state,
compact,
Some(&overlay),
);
}
}
}
}
}
#[cfg(test)]
mod session_picker_delete_tests {
use crate::app::actions::Action;
use crate::app::agent_view::AgentView;
use crate::app::agent_view::test_fixtures::make_agent;
use crate::app::app_view::{InputOutcome, SessionPickerEntry};
use crate::views::modal::ActiveModal;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
fn entry(id: &str) -> SessionPickerEntry {
SessionPickerEntry {
id: id.into(),
summary: id.into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: "/repo".into(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 0,
last_active_at: None,
branch: None,
repo_name: "repo".into(),
worktree_label: None,
card_detail: None,
}
}
fn open_picker(agent: &mut AgentView, entries: Vec<SessionPickerEntry>) {
agent.active_modal = Some(ActiveModal::SessionPicker {
state: crate::views::picker::PickerState::default(),
entries: Some(entries),
loading: false,
lanes: Default::default(),
previous_palette: None,
window: crate::views::modal_window::ModalWindowState::new(),
content_results: None,
content_loading: false,
deep_search_seq: 0,
entries_query: None,
source_filter: crate::views::session_picker::SourceFilter::default(),
pending_delete: None,
});
}
fn key(c: char) -> Event {
Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))
}
fn pending(agent: &AgentView) -> Option<String> {
match agent.active_modal.as_ref() {
Some(ActiveModal::SessionPicker { pending_delete, .. }) => pending_delete
.as_ref()
.map(|(_, session_id, _)| session_id.clone()),
_ => None,
}
}
#[test]
fn d_arms_then_y_confirms_delete() {
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0"), entry("s1")]);
// `d` arms the confirmation on the first selectable row (s0).
let out = agent.handle_palette_or_arg_input(&key('d'));
assert!(matches!(out, InputOutcome::Changed));
assert_eq!(pending(&agent).as_deref(), Some("s0"));
// `y` confirms — fires DeleteSession for the armed session.
let out = agent.handle_palette_or_arg_input(&key('y'));
assert!(
matches!(
out,
InputOutcome::Action(Action::DeleteSession {
ref source,
ref session_id,
ref cwd,
}) if source == "local" && session_id == "s0" && cwd == "/repo"
),
"y must confirm deletion of the armed session"
);
assert!(pending(&agent).is_none(), "pending cleared after confirm");
}
#[test]
fn d_arms_then_n_cancels() {
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0")]);
agent.handle_palette_or_arg_input(&key('d'));
assert_eq!(pending(&agent).as_deref(), Some("s0"));
let out = agent.handle_palette_or_arg_input(&key('n'));
assert!(matches!(out, InputOutcome::Changed));
assert!(pending(&agent).is_none(), "n cancels the confirmation");
}
#[test]
fn other_key_cancels_pending_delete() {
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0"), entry("s1")]);
agent.handle_palette_or_arg_input(&key('d'));
assert_eq!(pending(&agent).as_deref(), Some("s0"));
// A navigation key cancels the armed confirmation.
agent.handle_palette_or_arg_input(&key('j'));
assert!(
pending(&agent).is_none(),
"any non-y/d key cancels the pending delete"
);
}
#[test]
fn y_without_armed_confirmation_does_not_delete() {
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0")]);
// No `d` first — `y` is the copy hotkey, never a delete.
let out = agent.handle_palette_or_arg_input(&key('y'));
assert!(
!matches!(out, InputOutcome::Action(Action::DeleteSession { .. })),
"y alone must not delete"
);
assert!(pending(&agent).is_none());
}
/// Plain close (Esc) must surface `SessionPickerClosed` so the dispatch
/// layer can invalidate an in-flight list/search fetch — its landing
/// surface (the modal) is gone.
#[test]
fn esc_close_emits_session_picker_closed_action() {
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0")]);
let esc = Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let out = agent.handle_palette_or_arg_input(&esc);
assert!(
matches!(out, InputOutcome::Action(Action::SessionPickerClosed)),
"close must emit the fetch-invalidation action, got {out:?}"
);
assert!(agent.active_modal.is_none(), "modal cleared on close");
}
/// Chat-mode picker is conversations-only: `d` (local delete) must not
/// arm a confirmation and `f` must not cycle the hidden source filter.
#[test]
fn chat_mode_disables_delete_and_filter_keys() {
let mut agent = make_agent();
agent.app_chat_mode = true;
open_picker(&mut agent, vec![entry("c0"), entry("c1")]);
agent.handle_palette_or_arg_input(&key('d'));
assert!(
pending(&agent).is_none(),
"d must not arm delete under chat mode"
);
agent.handle_palette_or_arg_input(&key('f'));
let filter = match agent.active_modal.as_ref() {
Some(ActiveModal::SessionPicker { source_filter, .. }) => *source_filter,
_ => panic!("expected open session picker"),
};
assert_eq!(
filter,
crate::views::session_picker::SourceFilter::All,
"f must not cycle the hidden source filter under chat mode"
);
}
#[test]
fn foreign_row_refuses_delete_detail_and_worktree_actions() {
let mut agent = make_agent();
let mut foreign = entry("codex-session");
foreign.source = "codex".into();
open_picker(&mut agent, vec![foreign]);
let delete = agent.handle_palette_or_arg_input(&key('d'));
assert!(matches!(delete, InputOutcome::Changed));
assert!(pending(&agent).is_none(), "foreign delete must not arm");
let expand = agent.handle_palette_or_arg_input(&key('e'));
assert!(
!matches!(
expand,
InputOutcome::Action(Action::ExpandSessionCard { .. })
),
"foreign rows have no transcript detail"
);
let worktree = Event::Key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
let outcome = agent.handle_palette_or_arg_input(&worktree);
assert!(
!matches!(
outcome,
InputOutcome::Action(Action::PickSessionInWorktree(_))
),
"foreign rows cannot be resumed in worktrees"
);
assert!(
agent.active_modal.is_some(),
"refused actions keep picker open"
);
}
/// A server search matches conversation *content* too: a hit whose title
/// doesn't fuzzy-match the query must stay pickable in the modal
/// (`effective_filter_query` skips the local re-filter).
#[test]
fn server_search_hit_with_unrelated_title_is_pickable() {
let mut agent = make_agent();
agent.app_chat_mode = true;
let mut e = entry("conv-content-1");
// Title has no "hit" in it.
e.summary = "Quarterly roadmap notes".into();
e.source = "conversation".into();
open_picker(&mut agent, vec![e]);
if let Some(ActiveModal::SessionPicker {
state,
entries_query,
content_loading,
..
}) = agent.active_modal.as_mut()
{
state.query = "hit".into();
*entries_query = Some("hit".into());
// A re-search of the stamped query may be in flight: with the
// effective query empty, the input map appends NO "Searching…"
// header (same gate the renders use), so indices don't shift.
*content_loading = true;
// Grouped map: [repo header, row] — the row sits at index 1.
state.selected = 1;
}
let out = agent.handle_palette_or_arg_input(&key_code(KeyCode::Enter));
assert!(
matches!(out, InputOutcome::Action(Action::PickSession(0))),
"content-only search hit must be pickable, got {out:?}"
);
}
/// Canary: entries WITHOUT a matching fetch-query stamp keep the local
/// fuzzy filter — an unrelated title stays hidden from Enter.
#[test]
fn unstamped_entries_keep_local_fuzzy_filter() {
let mut agent = make_agent();
agent.app_chat_mode = true;
let mut e = entry("conv-content-1");
e.summary = "Quarterly roadmap notes".into();
e.source = "conversation".into();
open_picker(&mut agent, vec![e]);
if let Some(ActiveModal::SessionPicker { state, .. }) = agent.active_modal.as_mut() {
state.query = "hit".into();
state.selected = 1;
}
let out = agent.handle_palette_or_arg_input(&key_code(KeyCode::Enter));
assert!(
!matches!(out, InputOutcome::Action(Action::PickSession(_))),
"unstamped entries must still be fuzzy-filtered, got {out:?}"
);
}
fn key_code(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
/// Borrow the picker state of the open session picker for assertions.
fn picker_state(agent: &AgentView) -> &crate::views::picker::PickerState {
match agent.active_modal.as_ref() {
Some(ActiveModal::SessionPicker { state, .. }) => state,
_ => panic!("expected open session picker"),
}
}
#[test]
fn up_at_top_focuses_search_and_clears_selection() {
// Pin vim-mode off; this test asserts the non-vim picker path.
crate::appearance::cache::set_vim_mode(false);
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0"), entry("s1")]);
// Up from the first row moves focus to the search bar and hides the
// list selection highlight (the row should no longer look selected).
agent.handle_palette_or_arg_input(&key_code(KeyCode::Up));
let st = picker_state(&agent);
assert!(st.search_active, "search bar takes focus");
assert!(st.selection_hidden, "list selection highlight is cleared");
// Down from the search bar returns focus to the list and restores
// the highlight.
agent.handle_palette_or_arg_input(&key_code(KeyCode::Down));
let st = picker_state(&agent);
assert!(!st.search_active, "focus returns to the list");
assert!(!st.selection_hidden, "selection highlight is restored");
}
#[test]
fn down_at_bottom_focuses_search_and_clears_selection() {
// Pin vim-mode off; this test asserts the non-vim picker path.
crate::appearance::cache::set_vim_mode(false);
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0"), entry("s1")]);
// Move to the last selectable row, then Down again to reach search.
agent.handle_palette_or_arg_input(&key_code(KeyCode::Down));
agent.handle_palette_or_arg_input(&key_code(KeyCode::Down));
let st = picker_state(&agent);
assert!(st.search_active, "search bar takes focus from the bottom");
assert!(st.selection_hidden, "list selection highlight is cleared");
}
#[test]
fn typing_a_query_restores_selection() {
// Pin vim-mode off; this test asserts the non-vim picker path.
crate::appearance::cache::set_vim_mode(false);
let mut agent = make_agent();
open_picker(&mut agent, vec![entry("s0"), entry("s1")]);
// Arrow into the search bar (selection hidden), then type — a query
// makes the top match meaningful again, so the highlight returns.
agent.handle_palette_or_arg_input(&key_code(KeyCode::Up));
assert!(picker_state(&agent).selection_hidden);
agent.handle_palette_or_arg_input(&key('s'));
let st = picker_state(&agent);
assert!(st.search_active, "still typing in the search bar");
assert!(
!st.selection_hidden,
"typing a query restores the selection highlight"
);
}
}
#[cfg(test)]
mod command_palette_vim_input_tests {
use crate::app::agent_view::AgentView;
use crate::app::agent_view::test_fixtures::make_agent;
use crate::app::app_view::InputOutcome;
use crate::views::modal::ActiveModal;
use crate::views::picker::PickerState;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
// Open the command palette exactly as the Ctrl+P handler does: type-to-find
// INPUT mode (`input_active`) over the full palette entries.
fn open_command_palette(agent: &mut AgentView) {
agent.active_modal = Some(ActiveModal::CommandPalette {
entries: crate::views::modal::default_palette_entries(),
state: PickerState::input_active(),
window: crate::views::modal_window::ModalWindowState::new(),
});
}
fn key(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
}
fn esc() -> KeyEvent {
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
}
// Borrow the open command palette's picker state for assertions.
fn palette_state(agent: &AgentView) -> &PickerState {
match agent.active_modal.as_ref() {
Some(ActiveModal::CommandPalette { state, .. }) => state,
_ => panic!("expected open command palette"),
}
}
/// Headline command-palette vim flow — a CI-runnable mirror of the ignored
/// PTY scenario `vim_modal_command_palette.yaml`. Drives the real modal seam
/// (`handle_modal_key`) so both the chrome Esc handling and the picker's
/// `vim_normal_first: load_vim_mode()` wiring are exercised end to end.
#[test]
fn vim_command_palette_input_then_esc_to_nav_then_i_reenters() {
// CI defaults vim off and this dev machine's config sets it on, so pin.
crate::appearance::cache::set_vim_mode(true);
let mut agent = make_agent();
open_command_palette(&mut agent);
// Opens in INPUT mode: a letter types/filters immediately.
assert!(palette_state(&agent).search_active, "opens in input mode");
agent.handle_modal_key(&key('a'));
let st = palette_state(&agent);
assert_eq!(st.query, "a", "input mode: a letter filters");
assert!(st.search_active);
// First Esc clears the query via the modal chrome but stays in input.
agent.handle_modal_key(&esc());
let st = palette_state(&agent);
assert!(st.query.is_empty(), "Esc clears the query");
assert!(st.search_active, "still input after the first Esc");
// Second Esc (empty query) drops to NAV via the picker's vim Esc.
agent.handle_modal_key(&esc());
let st = palette_state(&agent);
assert!(!st.search_active, "second Esc drops to nav");
assert!(st.query.is_empty());
// NAV: a bare printable key must NOT type.
let out = agent.handle_modal_key(&key('b'));
let st = palette_state(&agent);
assert!(st.query.is_empty(), "nav: a bare letter does not filter");
assert!(!st.search_active);
assert!(
matches!(out, InputOutcome::Unchanged),
"nav letter is inert"
);
// `i` re-enters INPUT without typing; a letter then filters again.
agent.handle_modal_key(&key('i'));
assert!(palette_state(&agent).search_active, "i re-enters search");
assert!(palette_state(&agent).query.is_empty(), "i does not type");
agent.handle_modal_key(&key('c'));
assert_eq!(palette_state(&agent).query, "c", "typing filters again");
// Reset the global vim pin so it can't leak to later tests (libtest reuses threads).
crate::appearance::cache::set_vim_mode(false);
}
/// `/` is the other vim search-entry key: from NAV it re-enters INPUT.
#[test]
fn vim_command_palette_slash_reenters_search_from_nav() {
crate::appearance::cache::set_vim_mode(true);
let mut agent = make_agent();
open_command_palette(&mut agent);
// Drop to nav: type, then two Escs (clear query, then nav).
agent.handle_modal_key(&key('a'));
agent.handle_modal_key(&esc());
agent.handle_modal_key(&esc());
assert!(!palette_state(&agent).search_active, "in nav mode");
agent.handle_modal_key(&key('/'));
assert!(palette_state(&agent).search_active, "/ re-enters search");
assert!(palette_state(&agent).query.is_empty(), "/ does not type");
// Reset the global vim pin so it can't leak to later tests (libtest reuses threads).
crate::appearance::cache::set_vim_mode(false);
}
/// Vim OFF: the command palette stays type-to-filter — there is no nav mode,
/// so a letter keeps filtering even after Esc clears the query.
#[test]
fn non_vim_command_palette_stays_type_to_filter() {
crate::appearance::cache::set_vim_mode(false);
let mut agent = make_agent();
open_command_palette(&mut agent);
agent.handle_modal_key(&key('a'));
let st = palette_state(&agent);
assert_eq!(st.query, "a", "a letter filters");
assert!(st.search_active);
// Esc clears the query (chrome) but never drops to a nav mode.
agent.handle_modal_key(&esc());
assert!(
palette_state(&agent).query.is_empty(),
"Esc clears the query"
);
// A bare letter still types — no vim nav-mode suppression.
agent.handle_modal_key(&key('b'));
let st = palette_state(&agent);
assert_eq!(st.query, "b", "still type-to-filter (no nav mode)");
assert!(st.search_active);
}
// Drives the REAL command-palette render seam (draw_active_modal →
// picker::render_picker_in_modal → render_search_bar)
// — the path the bug was on — and asserts the cursor tracks focus.
#[test]
fn command_palette_search_bar_cursor_only_when_focused() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
let render_palette_search_row = |search_active: bool| -> (bool, String) {
let mut agent = make_agent();
open_command_palette(&mut agent);
if let Some(ActiveModal::CommandPalette { state, .. }) = agent.active_modal.as_mut() {
state.search_active = search_active;
}
let area = Rect::new(0, 0, 80, 24);
let mut buf = Buffer::empty(area);
agent.draw_active_modal(area, &mut buf, crate::theme::Theme::current(), false);
let theme = crate::theme::Theme::current();
let search_bar = match agent.active_modal.as_ref() {
Some(ActiveModal::CommandPalette { state, .. }) => {
state
.hit_areas
.as_ref()
.expect("render sets hit_areas")
.search_bar
}
_ => panic!("expected an open command palette"),
};
let y = search_bar.y;
let mut has_cursor = false;
let mut text = String::new();
for x in search_bar.x..search_bar.x + search_bar.width {
if let Some(cell) = buf.cell((x, y)) {
text.push_str(cell.symbol());
// The cursor is an inverse-video cell (bg == text_primary).
if cell.bg == theme.text_primary {
has_cursor = true;
}
}
}
(has_cursor, text)
};
let (focused_cursor, _) = render_palette_search_row(true);
assert!(
focused_cursor,
"command palette search bar should render a cursor when search_active",
);
let (unfocused_cursor, unfocused_text) = render_palette_search_row(false);
assert!(
!unfocused_cursor,
"command palette search bar must not render a cursor when not search_active",
);
assert!(
unfocused_text.contains("/ to search"),
"unfocused command palette should show the `/ to search` placeholder, got {unfocused_text:?}",
);
}
}