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).
2777 lines
122 KiB
Rust
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, ®, 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:?}",
|
|
);
|
|
}
|
|
}
|