//! Modal dialogs and the [`ActiveModal`] enum. //! //! [`ActiveModal`] wraps concrete modal instances for storage on //! `AgentView`. Picker-based variants (`CommandPalette`, `ArgPicker`, //! `SessionPicker`, `DocPicker`, `DocViewer`) use the shared //! [`ModalWindow`](super::modal_window) component for chrome and //! [`render_picker_content`](super::picker::render_picker_content) for //! entry rendering. `EditConfirm` is a bar-style overlay (not a popup). //! //! `ModalConfirmation` is a small dialog that blocks all input until //! the user presses one of the listed keys. use crate::docs::{DocEntry, default_howto_entries}; use crate::theme::Theme; use crate::views::modal_window::ModalWindowState; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_width::UnicodeWidthStr; /// A blocking confirmation dialog with typed results. /// /// `R` is the result type — each dialog use-case defines its own enum. /// Key-matching is generic; labels are computed per-variant at render time. pub struct ModalConfirmation { /// Available options (key → result). Labels are derived from `R` at render time. pub options: Vec>, } /// One option in a modal dialog. pub struct ModalOption { /// The key that triggers this option (e.g., 'y', 'n', 'x'). pub key: char, /// The result produced when this option is chosen. pub result: R, } impl ModalConfirmation { /// Check if a character matches any option. Returns the result if matched. pub fn resolve(&self, ch: char) -> Option<&R> { self.options.iter().find(|o| o.key == ch).map(|o| &o.result) } } /// Result of the edit-confirmation modal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EditConfirmResult { /// Save changes (back to queue, or save & send if drain-blocked). Save, /// Discard changes (revert to original; sends original if drain-blocked). Discard, /// Delete the prompt entirely from the queue. Delete, /// Cancel — dismiss the dialog, stay in editing mode. Cancel, } impl EditConfirmResult { /// Dynamic label based on whether the agent is waiting to drain. pub fn label(&self, drain_blocked: bool) -> &'static str { match (self, drain_blocked) { (Self::Save, false) => "save", (Self::Save, true) => "save & send", (Self::Discard, false) => "discard changes", (Self::Discard, true) => "discard & send", (Self::Delete, _) => "delete prompt", (Self::Cancel, _) => "cancel", } } } impl ModalConfirmation { /// Create the edit confirmation modal. /// /// Always shows three options: save (y), discard (n), delete (x). /// Labels are computed dynamically at render time based on `drain_blocked`. pub fn edit_confirm() -> Self { Self { options: vec![ ModalOption { key: 'y', result: EditConfirmResult::Save, }, ModalOption { key: 'n', result: EditConfirmResult::Discard, }, ModalOption { key: 'x', result: EditConfirmResult::Delete, }, ], } } } /// Result of the reset-settings confirmation modal. /// `y` → Reset, `n`/`Esc`/`F2`/`Ctrl+,` → Cancel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResetSettingsResult { /// Restore the setting to its registered default. Reset, /// Cancel — return to the Settings modal unchanged. Cancel, } impl ResetSettingsResult { /// Label for the y/n buttons rendered in the modal footer. pub fn label(self) -> &'static str { match self { Self::Reset => "reset", Self::Cancel => "cancel", } } } /// Shortcut IDs for the reset-confirm footer buttons (1-2, avoiding /// the extensions modal's 100+ range). pub const RESET_CONFIRM_YES_ID: usize = 1; pub const RESET_CONFIRM_NO_ID: usize = 2; impl ModalConfirmation { /// Create the reset-settings confirmation modal (`y`/`n`). pub fn reset_settings() -> Self { Self { options: vec![ ModalOption { key: 'y', result: ResetSettingsResult::Reset, }, ModalOption { key: 'n', result: ResetSettingsResult::Cancel, }, ], } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CancelTurnChoice { StopRunning, ContinueToRun, AlwaysStop, AlwaysContinue, } impl CancelTurnChoice { pub const ALL: [CancelTurnChoice; 4] = [ CancelTurnChoice::StopRunning, CancelTurnChoice::ContinueToRun, CancelTurnChoice::AlwaysStop, CancelTurnChoice::AlwaysContinue, ]; pub fn label(&self) -> &'static str { match self { Self::StopRunning => "Stop running", Self::ContinueToRun => "Continue to run", Self::AlwaysStop => "Always stop", Self::AlwaysContinue => "Always continue", } } } pub struct CancelTurnViewState { pub active_idx: usize, pub running_count: usize, } /// Returns a ready-to-open DocPicker modal for the how-to guides list. /// /// `previous_palette` is the saved command-palette state; when provided, /// pressing Esc in the doc picker restores that palette instead of /// closing the modal outright. pub fn howto_list_modal(previous_palette: Option) -> ActiveModal { ActiveModal::DocPicker { entries: default_howto_entries(), state: crate::views::picker::PickerState::default(), previous_palette, window: ModalWindowState::new(), } } /// The currently active modal dialog, if any. /// /// Each variant wraps a `ModalConfirmation` with its concrete result /// type plus any context needed for resolution (e.g., pending focus target). pub enum ActiveModal { /// Confirmation for leaving a dirty queued-prompt edit. EditConfirm { modal: ModalConfirmation, /// Where to switch focus after confirmation (if not Cancel). pending_target: super::agent::ActivePane, }, /// Command palette (Ctrl+P). CommandPalette { entries: Vec, state: crate::views::picker::PickerState, /// Shared modal window chrome state. window: ModalWindowState, }, /// Argument picker for commands with pre-defined choices (model, theme). /// Opens when selecting such a command from the command palette. ArgPicker { /// Command name (e.g., "model", "theme"). command: String, /// Args query passed to `suggest_args` (empty = first phase; for `/model`, /// a trailing-space query enters the reasoning-effort sub-menu). args_query: String, /// Filtered items (re-filtered from original_items on query change). items: Vec, /// Original items from suggest_args() (source for filtering). original_items: Vec, /// Unified picker state. state: crate::views::picker::PickerState, /// Previous command palette state (if opened from palette). Restored on Esc. previous_palette: Option, /// Shared modal window chrome state. window: ModalWindowState, }, /// Session picker (opened from /resume command or command palette). SessionPicker { /// Unified picker state. state: crate::views::picker::PickerState, /// Fetched session entries (None = not yet loaded). entries: Option>, /// Whether the session list is being fetched. loading: bool, /// Foreign lane completion and deferred native-lane notice. lanes: crate::views::session_picker::SessionPickerLanes, /// Previous command palette state (if opened from palette). Restored on Esc. previous_palette: Option, /// Shared modal window chrome state. window: ModalWindowState, /// Content-based (deep search) results from ACP session search. content_results: Option>, /// Whether a deep search is currently in flight. content_loading: bool, /// Monotonically increasing sequence number for deep search requests. deep_search_seq: u64, /// The search query `entries` were server-fetched with (`None` = /// unfiltered fetch). See /// [`crate::views::session_picker::effective_filter_query`]. entries_query: Option, /// Source filter for the modal session picker. source_filter: crate::views::session_picker::SourceFilter, /// Session armed for delete, captured as `(source, session_id, cwd)` when /// `d` is pressed so the `y` confirm always has a valid cwd even if /// the picker lists change underneath it. `Some` only while the /// focused row is armed; cleared on cancel / completion. pending_delete: Option<(String, String, String)>, }, /// How-to documentation list modal (wider picker style). DocPicker { entries: Vec, state: crate::views::picker::PickerState, /// Previous command palette state (if opened from palette). Restored on Esc. previous_palette: Option, /// Shared modal window chrome state. window: ModalWindowState, }, /// Documentation panel showing full content of a selected how-to guide. DocViewer { title: String, content: String, /// Vertical scroll offset in lines. scroll: u16, /// Shared modal window chrome state. window: ModalWindowState, /// Cached pre-rendered markdown lines + the width they were /// rendered at. Invalidated when the content area width changes /// (e.g. terminal resize) so lines are re-parsed at the new width. cached_lines: Option<(u16, Vec>)>, /// Palette snapshot shuttled from DocPicker, passed back when returning /// to the doc list on Esc so the DocPicker can still restore the palette. previous_palette: Option, /// When true, Esc closes the modal directly instead of returning /// to the DocPicker list (used for /release-notes). standalone: bool, }, /// All-shortcuts cheatsheet for the current view/state. /// Rendered via the unified picker (same look as CommandPalette). /// See `crate::views::shortcuts_help` for build/render/input logic. ShortcutsHelp { /// Snapshot of the entries (section headers + hints) at open time. entries: Vec, /// Unified picker state (search query, selection, scroll, hit areas). state: crate::views::picker::PickerState, /// Modal window chrome state (close button, scroll region). window: crate::views::modal_window::ModalWindowState, /// When true, dimmed (out-of-context) shortcuts are hidden. filter_active: bool, /// Indices into CATEGORY_ORDER that are collapsed. Default: all except 0. collapsed_sections: std::collections::HashSet, /// Rows whose inline help is expanded under the list (pattern A). expanded_ids: std::collections::HashSet, /// Browse list vs in-modal detail page (pattern B). mode: crate::views::shortcuts_help::ShortcutsHelpMode, }, /// Memory browser modal (/memory). MemoryBrowser { state: Box, }, /// Settings modal (F2, /settings, palette). Boxed — large state. Settings { state: Box, }, /// Reset-settings confirmation, stacked above Settings. /// /// The underlying `SettingsModalState` is moved in/out so cancel /// preserves the user's filter/scroll position. The setting key /// lives only here (single source of truth for dispatch). ResetSettingsConfirm { modal: ModalConfirmation, /// Setting key being reset. key: crate::settings::SettingKey, /// Preserved settings state, restored by both choice branches. settings_state: Box, }, /// Modal preview for a `#` remember note. Shows the raw text immediately; /// the LLM-enhanced version arrives asynchronously and can be toggled with Tab. RememberNoteReview { raw_content: String, enhanced_content: Option, showing_enhanced: bool, scroll: u16, window: ModalWindowState, cached_lines: Option<(u16, Vec>)>, cwd: std::path::PathBuf, agent_id: crate::app::agent::AgentId, /// Monotonic nonce to correlate async rewrite results with the modal /// that requested them, preventing stale results from populating a /// different note's review modal. rewrite_nonce: u64, }, } /// Snapshot of the command palette state, saved when opening an arg picker /// and restored on Esc. #[derive(Debug, Clone)] pub struct PaletteSnapshot { pub entries: Vec, pub state: crate::views::picker::PickerState, } /// A single entry in the command palette. #[derive(Debug, Clone)] pub struct PaletteEntry { /// Display label (e.g., "New Session"). pub label: String, /// Keyboard shortcut hint (e.g., "Ctrl+N"). pub shortcut: String, /// Which palette command this executes. pub command: PaletteCommand, } /// Commands available in the command palette. #[derive(Debug, Clone)] pub enum PaletteCommand { NewSession, NewSessionInWorktree, Home, Quit, /// Execute a slash command by inserting it into the prompt. SlashCommand(String), /// Non-selectable section header for visual grouping. SectionHeader(String), /// Open the how-to documentation picker. HowTo, /// Open the keyboard shortcuts cheatsheet (Ctrl+.). KeyboardShortcuts, /// Open the memory browser modal. Memory, /// Open the Extensions modal on a specific tab. Used by palette /// entries that don't have a corresponding slash command (e.g. /// "Skills") and to keep direct entries consistent. OpenExtensionsTab(crate::views::extensions_modal::ExtensionsTab), /// Open the settings modal. OpenSettings, /// Open the Agents modal (listing all agent definitions). OpenAgentsModal, } /// Build the default set of palette entries with section grouping. pub fn default_palette_entries() -> Vec { let entries = vec![ PaletteEntry { label: "Session".into(), shortcut: String::new(), command: PaletteCommand::SectionHeader("Session".into()), }, PaletteEntry { label: "New Session".into(), shortcut: "Ctrl+N".into(), command: PaletteCommand::NewSession, }, PaletteEntry { label: "New Session in Worktree".into(), shortcut: "Ctrl+P → worktree".into(), command: PaletteCommand::NewSessionInWorktree, }, PaletteEntry { label: "Agent Dashboard".into(), shortcut: "/dashboard".into(), command: PaletteCommand::SlashCommand("/dashboard".into()), }, PaletteEntry { label: "Back to Home".into(), shortcut: "/home".into(), command: PaletteCommand::Home, }, PaletteEntry { label: "Resume Session".into(), shortcut: "/resume".into(), command: PaletteCommand::SlashCommand("/resume".into()), }, PaletteEntry { label: "Rename Session".into(), shortcut: "/rename ".into(), command: PaletteCommand::SlashCommand("/rename ".into()), }, PaletteEntry { label: "Session Info".into(), shortcut: "/session-info".into(), command: PaletteCommand::SlashCommand("/session-info".into()), }, PaletteEntry { label: "Send Feedback".into(), shortcut: "/feedback".into(), command: PaletteCommand::SlashCommand("/feedback ".into()), }, PaletteEntry { label: "Context".into(), shortcut: String::new(), command: PaletteCommand::SectionHeader("Context".into()), }, PaletteEntry { label: "Compact History".into(), shortcut: "/compact".into(), command: PaletteCommand::SlashCommand("/compact".into()), }, PaletteEntry { label: "Context Usage".into(), shortcut: "/context".into(), command: PaletteCommand::SlashCommand("/context".into()), }, PaletteEntry { label: "View Plan".into(), shortcut: "/view-plan".into(), command: PaletteCommand::SlashCommand("/view-plan".into()), }, PaletteEntry { label: "Memory".into(), shortcut: "/memory".into(), command: PaletteCommand::Memory, }, PaletteEntry { label: "Model & Input".into(), shortcut: String::new(), command: PaletteCommand::SectionHeader("Model & Input".into()), }, PaletteEntry { label: "Switch Model".into(), shortcut: "/model".into(), command: PaletteCommand::SlashCommand("/model ".into()), }, PaletteEntry { label: "Always Approve Mode".into(), shortcut: "/always-approve".into(), command: PaletteCommand::SlashCommand("/always-approve".into()), }, PaletteEntry { label: "Multiline Input".into(), shortcut: "/multiline".into(), command: PaletteCommand::SlashCommand("/multiline".into()), }, PaletteEntry { label: "Tools".into(), shortcut: String::new(), command: PaletteCommand::SectionHeader("Tools".into()), }, PaletteEntry { label: "Hooks".into(), shortcut: "/hooks".into(), command: PaletteCommand::OpenExtensionsTab( crate::views::extensions_modal::ExtensionsTab::Hooks, ), }, PaletteEntry { label: "Plugins".into(), shortcut: "/plugins".into(), command: PaletteCommand::OpenExtensionsTab( crate::views::extensions_modal::ExtensionsTab::Plugins, ), }, PaletteEntry { label: "Skills".into(), shortcut: "/skills".into(), command: PaletteCommand::OpenExtensionsTab( crate::views::extensions_modal::ExtensionsTab::Skills, ), }, PaletteEntry { label: "MCP Servers".into(), shortcut: "/mcps".into(), command: PaletteCommand::OpenExtensionsTab( crate::views::extensions_modal::ExtensionsTab::McpServers, ), }, PaletteEntry { label: "Manage Agents".into(), shortcut: "/config-agents".into(), command: PaletteCommand::OpenAgentsModal, }, PaletteEntry { label: "Other".into(), shortcut: String::new(), command: PaletteCommand::SectionHeader("Other".into()), }, PaletteEntry { label: "Switch Theme".into(), shortcut: "/theme".into(), command: PaletteCommand::SlashCommand("/theme ".into()), }, PaletteEntry { label: "Settings".into(), shortcut: "F2".into(), command: PaletteCommand::OpenSettings, }, PaletteEntry { label: "Keyboard Shortcuts".into(), shortcut: if crate::actions::ctrl_dot_unreliable() { "Ctrl+X".into() } else { "Ctrl+.".into() }, command: PaletteCommand::KeyboardShortcuts, }, PaletteEntry { label: "How-to Guides".into(), shortcut: "/docs".into(), command: PaletteCommand::HowTo, }, PaletteEntry { label: "Quit".into(), shortcut: "Ctrl+Q".into(), command: PaletteCommand::Quit, }, ]; entries } #[allow(clippy::collapsible_if)] /// Filter palette entries for search, preserving section headers when any item in the section matches. pub fn filter_palette_entries(query: &str) -> Vec { let all = default_palette_entries(); let query_lower = query.to_lowercase(); if query_lower.is_empty() { return all; } let mut result = Vec::new(); let mut pending_header: Option = None; let mut section_has_match = false; for entry in all { if matches!(entry.command, PaletteCommand::SectionHeader(_)) { if let Some(h) = pending_header.take() { if section_has_match { result.push(h); } } pending_header = Some(entry); section_has_match = false; } else { let matches = entry.label.to_lowercase().contains(&query_lower) || entry.shortcut.to_lowercase().contains(&query_lower); if matches { if let Some(h) = pending_header.take() { result.push(h); section_has_match = true; } result.push(entry); } } } if let Some(h) = pending_header { if section_has_match { result.push(h); } } result } impl ActiveModal { pub fn hint_pairs(&self, drain_blocked: bool) -> Vec<(char, &'static str)> { match self { ActiveModal::EditConfirm { modal, .. } => modal .options .iter() .map(|o| (o.key, o.result.label(drain_blocked))) .collect(), ActiveModal::ResetSettingsConfirm { modal, .. } => modal .options .iter() .map(|o| (o.key, o.result.label())) .collect(), ActiveModal::CommandPalette { .. } | ActiveModal::ArgPicker { .. } | ActiveModal::SessionPicker { .. } | ActiveModal::DocPicker { .. } | ActiveModal::DocViewer { .. } | ActiveModal::ShortcutsHelp { .. } | ActiveModal::MemoryBrowser { .. } | ActiveModal::Settings { .. } | ActiveModal::RememberNoteReview { .. } => vec![], } } pub fn message(&self, drain_blocked: bool) -> &str { match self { ActiveModal::EditConfirm { .. } => { if drain_blocked { "Save and send?" } else { "Save changes?" } } ActiveModal::CommandPalette { .. } => "Commands", ActiveModal::SessionPicker { .. } => "Resume session", ActiveModal::ArgPicker { command, args_query, .. } => match command.as_str() { "model" | "m" if !args_query.is_empty() => "Pick reasoning effort", "model" | "m" => "Pick model", "theme" | "t" => "Pick theme", _ => "Pick option", }, ActiveModal::DocPicker { .. } => "How-to Guides", ActiveModal::DocViewer { title, .. } => title.as_str(), ActiveModal::ShortcutsHelp { .. } => "Keyboard Shortcuts", ActiveModal::MemoryBrowser { .. } => "Memory", ActiveModal::Settings { .. } => crate::views::settings_modal::MODAL_TITLE, ActiveModal::ResetSettingsConfirm { .. } => "Reset setting?", ActiveModal::RememberNoteReview { .. } => "Memory Note", } } } /// Build the reset-confirmation prompt, e.g. "Reset 'Compact mode' to default (off)?". /// Returns `None` if the modal isn't `ResetSettingsConfirm` or the key is unknown. pub fn reset_confirm_prompt(modal: &ActiveModal) -> Option { let ActiveModal::ResetSettingsConfirm { key, settings_state, .. } = modal else { return None; }; let meta = settings_state.registry.find(key)?; let default = crate::settings::default_value_for(meta); Some(format!( "Reset '{}' to default ({})?", meta.label, format_default_for_prompt(&meta.kind, &default), )) } /// Abbreviated title breadcrumb for the reset-confirm dialog, e.g. "Reset 'Compact mode'". pub fn reset_confirm_breadcrumb(modal: &ActiveModal) -> Option { let ActiveModal::ResetSettingsConfirm { key, settings_state, .. } = modal else { return None; }; let meta = settings_state.registry.find(key)?; Some(format!("Reset '{}'", meta.label)) } /// Format a `SettingValue` for the prompt's `()` display. fn format_default_for_prompt( kind: &crate::settings::SettingKind, value: &crate::settings::SettingValue, ) -> String { use crate::settings::{SettingKind, SettingValue}; match value { SettingValue::Bool(true) => "on".to_owned(), SettingValue::Bool(false) => "off".to_owned(), SettingValue::Enum(canonical) => { if let SettingKind::Enum { choices, .. } = kind { for c in *choices { if c.canonical == *canonical { return c.display.to_owned(); } } } (*canonical).to_owned() } SettingValue::String(s) => format!("\"{s}\""), SettingValue::Int(i) => i.to_string(), } } /// A clickable button region from the rendered modal. #[derive(Debug, Clone, Copy)] pub struct ModalButtonHit { pub rect: Rect, pub key: char, } /// Result of rendering a modal overlay. pub struct ModalRenderResult { /// Hit areas for each button (for mouse click/hover). pub buttons: Vec, } /// Render the modal overlay: dim screen + styled bar at bottom. /// /// Layout of the bar: /// ```text /// Save changes? [ y:save ] [ n:discard ] /// ``` /// /// - Message in `text_primary` bold /// - Each button: dark bg pill, lighter on hover /// - Screen above the bar is dimmed to `gray_dim` fg + `bg_base` bg /// /// `bar_area` is the 1-line rect where the bar renders (shortcuts bar slot). /// `dim_area` is everything above the bar (to be dimmed). /// `hovered_key` is the button key currently under the mouse (if any). pub fn render_modal_overlay( buf: &mut Buffer, modal: &ActiveModal, bar_area: Rect, dim_area: Rect, hovered_key: Option, drain_blocked: bool, ) -> ModalRenderResult { let theme = Theme::current(); let dim_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base); for y in dim_area.y..dim_area.y + dim_area.height { for x in dim_area.x..dim_area.x + dim_area.width { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_style(dim_style); } } } if bar_area.height == 0 || bar_area.width < 10 { return ModalRenderResult { buttons: Vec::new(), }; } let bar_bg = theme.bg_base; let bar_style = Style::default().fg(theme.text_primary).bg(bar_bg); for x in bar_area.x..bar_area.x + bar_area.width { if let Some(cell) = buf.cell_mut((x, bar_area.y)) { cell.reset(); cell.set_style(bar_style); } } let msg = modal.message(drain_blocked); let msg_style = Style::default() .fg(theme.text_primary) .bg(bar_bg) .add_modifier(Modifier::BOLD); let msg_span = Span::styled(msg, msg_style); buf.set_span(bar_area.x, bar_area.y, &msg_span, bar_area.width); let mut x = bar_area.x + msg.width() as u16 + 2; let btn_bg = theme.bg_dark; let btn_hover_bg = theme.gray_dim; let pairs = modal.hint_pairs(drain_blocked); let mut buttons = Vec::with_capacity(pairs.len()); for (key, label) in &pairs { let btn_text_w = 1 + 1 + 1 + label.width() + 1; let btn_w = btn_text_w as u16; if x + btn_w > bar_area.x + bar_area.width { break; } let is_hovered = hovered_key == Some(*key); let bg = if is_hovered { btn_hover_bg } else { btn_bg }; let key_style = Style::default() .fg(theme.accent_user) .bg(bg) .add_modifier(Modifier::BOLD); let label_style = Style::default().fg(theme.gray).bg(bg); let pad_style = Style::default().bg(bg); let btn_rect = Rect { x, y: bar_area.y, width: btn_w, height: 1, }; buf.set_span(x, bar_area.y, &Span::styled(" ", pad_style), 1); buf.set_span( x + 1, bar_area.y, &Span::styled(key.to_string(), key_style), 1, ); buf.set_span(x + 2, bar_area.y, &Span::styled(":", label_style), 1); buf.set_span( x + 3, bar_area.y, &Span::styled(label.to_string(), label_style), label.width() as u16, ); buf.set_span( x + 3 + label.width() as u16, bar_area.y, &Span::styled(" ", pad_style), 1, ); buttons.push(ModalButtonHit { rect: btn_rect, key: *key, }); x += btn_w + 1; } ModalRenderResult { buttons } } /// vpad(1) + title(1) + count(1) + gap(1) + 4 options + vpad(1) = 9 const CANCEL_TURN_PANEL_HEIGHT: u16 = 9; pub fn cancel_turn_panel_height(screen_h: u16) -> u16 { let cap = (screen_h as u32 * 33 / 100) .max(8) .min(screen_h as u32 * 80 / 100) as u16; CANCEL_TURN_PANEL_HEIGHT.min(cap) } pub fn render_cancel_turn_panel( buf: &mut Buffer, area: Rect, state: &CancelTurnViewState, focused: bool, button_rects: &mut Vec, ) { button_rects.clear(); let theme = Theme::current(); buf.set_style(area, Style::default().bg(theme.bg_light)); let accent_style = Style::default().fg(theme.warning); for row in area.y..area.y + area.height { if let Some(cell) = buf.cell_mut((area.x, row)) { cell.set_symbol(crate::glyphs::accent_bar()); cell.set_style(accent_style); } } let content_x = area.x + 3; let content_w = area.width.saturating_sub(5) as usize; let mut y = area.y + 1; let title_style = Style::default() .fg(theme.accent_user) .add_modifier(Modifier::BOLD); buf.set_line( content_x, y, &Line::from(Span::styled( "Subagents are still running. Stop them?", title_style, )), content_w as u16, ); y += 1; let count_text = if state.running_count == 1 { "1 subagent running".to_string() } else { format!("{} subagents running", state.running_count) }; buf.set_line( content_x, y, &Line::from(Span::styled(count_text, Style::default().fg(theme.gray))), content_w as u16, ); y += 2; for (i, choice) in CancelTurnChoice::ALL.iter().enumerate() { if y >= area.y + area.height { break; } let is_cursor = i == state.active_idx; let row_bg = if is_cursor && focused { theme.bg_visual } else { theme.bg_light }; let row_rect = Rect { x: content_x.saturating_sub(1), y, width: content_w as u16 + 2, height: 1, }; buf.set_style(row_rect, Style::default().bg(row_bg)); button_rects.push(row_rect); let marker = if is_cursor { crate::glyphs::filled_dot() } else { "\u{25CB}" }; let num = (i + 1).to_string(); let num_style = Style::default().fg(theme.accent_user).bg(row_bg); let marker_style = if is_cursor { Style::default().fg(theme.accent_user).bg(row_bg) } else { Style::default().fg(theme.gray).bg(row_bg) }; let label_style = Style::default() .fg(theme.text_primary) .bg(row_bg) .add_modifier(if is_cursor { Modifier::BOLD } else { Modifier::empty() }); let line = Line::from(vec![ Span::styled(format!("{num} "), num_style), Span::styled(format!("({marker}) "), marker_style), Span::styled(choice.label(), label_style), ]); buf.set_line(content_x, y, &line, content_w as u16); y += 1; } if !focused { crate::render::color::blend_area(buf, area, Some((theme.bg_light, 0.66)), None); } } /// Apply scroll-key dispatch for a DocViewer modal. Returns `true` if the key /// was handled (caller should return `InputOutcome::Changed`). pub fn apply_doc_scroll(code: crossterm::event::KeyCode, scroll: &mut u16) -> bool { use crossterm::event::KeyCode; match code { KeyCode::Down | KeyCode::Char('j') => { *scroll = scroll.saturating_add(3); true } KeyCode::Up | KeyCode::Char('k') => { *scroll = scroll.saturating_sub(3); true } KeyCode::PageDown => { *scroll = scroll.saturating_add(20); true } KeyCode::PageUp => { *scroll = scroll.saturating_sub(20); true } KeyCode::Home => { *scroll = 0; true } KeyCode::End => { *scroll = u16::MAX; true } _ => false, } } /// Apply a signed line delta to a DocViewer scroll offset (positive = down). pub fn apply_doc_scroll_delta(scroll: &mut u16, lines: i32) { if lines == 0 { return; } if lines > 0 { *scroll = scroll.saturating_add(lines as u16); } else { *scroll = scroll.saturating_sub(lines.unsigned_abs() as u16); } } /// Apply mouse-wheel events to a DocViewer scroll offset. Returns `true` if /// the event was a scroll and the offset was updated. pub fn apply_doc_mouse_scroll(kind: crossterm::event::MouseEventKind, scroll: &mut u16) -> bool { use crossterm::event::MouseEventKind; match kind { MouseEventKind::ScrollDown => { apply_doc_scroll_delta(scroll, 3); true } MouseEventKind::ScrollUp => { apply_doc_scroll_delta(scroll, -3); true } _ => false, } } const DOCS_USER_GUIDE_REL: &str = "docs/user-guide"; /// Prefer keeping the on-disk path when width is tight. fn fit_docs_ask_kigi_tip(docs_path: &str, width: usize) -> String { use crate::render::line_utils::truncate_str; if width == 0 { return String::new(); } let long = format!("Tip · Ask Kigi about the docs ({docs_path}) — e.g. \"how do I set up MCP?\""); if long.width() <= width { return long; } let short = format!("Tip · Ask Kigi about the docs · {docs_path}"); if short.width() <= width { return short; } let path_only = format!("Tip · {docs_path}"); if path_only.width() <= width { return path_only; } const PREFIX: &str = "Tip · "; let budget = width.saturating_sub(PREFIX.width()); if budget == 0 { return truncate_str("Tip", width); } format!("{PREFIX}{}", truncate_str(docs_path, budget)) } pub fn render_doc_picker_overlay( buf: &mut ratatui::buffer::Buffer, area: Rect, window: &mut super::modal_window::ModalWindowState, entries: &[DocEntry], state: &mut super::picker::PickerState, compact: bool, theme: &Theme, ) { use super::modal_window::{ self as mw, ModalSizing, ModalWindowConfig, Shortcut, footer_lines_with_tip_gap, render_centered_tip_footer, split_content_for_tip_footer, }; use super::picker::{self, PickerEntry, PickerRow}; 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 non_sel = vec![false; filtered.len()]; let mut picker_shortcuts: Vec> = 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, }, ]; mw::push_vim_nav_search_hint(&mut picker_shortcuts, state.search_active); let base_sizing = ModalSizing { width_pct: 0.70, max_width: 120, min_width: 44, v_margin: 4, h_pad: 2, v_pad: 1, footer_lines: 2, } .with_compact(compact); let sizing = ModalSizing { footer_lines: footer_lines_with_tip_gap(area, &base_sizing, &picker_shortcuts), ..base_sizing }; let modal_config = ModalWindowConfig { title: "How-to Guides", tabs: None, shortcuts: &picker_shortcuts, sizing, fold_info: None, }; let Some(mca) = mw::render_modal_window(buf, area, window, &modal_config, theme) else { return; }; let (picker_area, tip_area) = split_content_for_tip_footer(mca.content); if let Some(tip_rect) = tip_area { let docs_path = crate::util::display_user_kigi_path(DOCS_USER_GUIDE_REL); let tip_line = fit_docs_ask_kigi_tip(&docs_path, tip_rect.width as usize); render_centered_tip_footer(buf, tip_rect, theme, &tip_line); } const NARROW_THRESHOLD: u16 = 70; let narrow = picker_area.width < NARROW_THRESHOLD; let desc_slices: Vec> = if narrow { filtered .iter() .map(|(_, e)| vec![e.description.as_str()]) .collect() } else { Vec::new() }; let selected_orig = filtered .get(state.selected) .map(|(o, _)| *o) .unwrap_or(usize::MAX); let picker_entries: Vec> = filtered .iter() .enumerate() .map(|(i, (orig_idx, e))| { PickerEntry::Row(PickerRow { label: &e.title, right_label: if narrow { "" } else { &e.description }, selected: *orig_idx == selected_orig, expanded: narrow, fields: &[], description_lines: if narrow { &desc_slices[i] } else { &[] }, summary_lines: &[], dimmed: false, indent: 0, badge: "", badge_color: None, collapsible: false, }) }) .collect(); picker::render_picker_in_modal( buf, picker_area, mca.inner_x, mca.inner_width, theme, state, &picker_entries, &non_sel, false, ); } /// Render a DocViewer overlay: modal window chrome + cached markdown content. #[allow(clippy::too_many_arguments)] pub fn render_doc_viewer_overlay( buf: &mut ratatui::buffer::Buffer, area: Rect, window: &mut super::modal_window::ModalWindowState, title: &str, content: &str, scroll: &mut u16, cached_lines: &mut Option<(u16, Vec>)>, compact: bool, theme: &Theme, ) { use ratatui::widgets::{Paragraph, Widget, Wrap}; let doc_shortcuts = vec![ super::modal_window::Shortcut { label: "\u{2191}/\u{2193} scroll", clickable: false, id: 0, }, super::modal_window::Shortcut { label: "Esc back", clickable: false, id: 0, }, ]; let modal_config = super::modal_window::ModalWindowConfig { title, tabs: None, shortcuts: &doc_shortcuts, sizing: super::modal_window::ModalSizing { width_pct: 0.80, max_width: 120, min_width: 44, v_margin: 4, h_pad: 2, v_pad: 1, footer_lines: 2, } .with_compact(compact), fold_info: None, }; if let Some(super::modal_window::ModalContentArea { content: content_area, .. }) = super::modal_window::render_modal_window(buf, area, window, &modal_config, theme) { 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(content); let output = mc.output(w as usize); let lines: Vec> = 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 = all_lines .iter() .skip(start) .take(content_area.height as usize) .cloned() .collect(); let para = Paragraph::new(visible).wrap(Wrap { trim: false }); para.render(content_area, buf); } } #[cfg(test)] mod doc_viewer_scroll_tests { use super::{apply_doc_mouse_scroll, apply_doc_scroll, apply_doc_scroll_delta}; use crossterm::event::{KeyCode, MouseEventKind}; #[test] fn apply_doc_scroll_moves_by_key() { let mut scroll = 10u16; assert!(apply_doc_scroll(KeyCode::Down, &mut scroll)); assert_eq!(scroll, 13); assert!(apply_doc_scroll(KeyCode::Up, &mut scroll)); assert_eq!(scroll, 10); assert!(apply_doc_scroll(KeyCode::Home, &mut scroll)); assert_eq!(scroll, 0); assert!(apply_doc_scroll(KeyCode::End, &mut scroll)); assert_eq!(scroll, u16::MAX); } #[test] fn apply_doc_scroll_delta_saturates_at_zero() { let mut scroll = 2u16; apply_doc_scroll_delta(&mut scroll, -10); assert_eq!(scroll, 0); apply_doc_scroll_delta(&mut scroll, 4); assert_eq!(scroll, 4); } #[test] fn apply_doc_mouse_scroll_handles_wheel() { let mut scroll = 0u16; assert!(apply_doc_mouse_scroll( MouseEventKind::ScrollDown, &mut scroll )); assert_eq!(scroll, 3); assert!(apply_doc_mouse_scroll( MouseEventKind::ScrollUp, &mut scroll )); assert_eq!(scroll, 0); assert!(!apply_doc_mouse_scroll(MouseEventKind::Moved, &mut scroll)); } } #[cfg(test)] mod palette_tests { use super::*; #[test] fn default_palette_includes_dashboard() { let entries = default_palette_entries(); let has_dashboard = entries.iter().any(|e| { matches!( & e.command, PaletteCommand::SlashCommand(s) if s.trim() == "/dashboard" ) }); assert!( has_dashboard, "/dashboard entry must be present in the palette so users can switch between agents" ); let labelled = entries.iter().any(|e| e.label == "Agent Dashboard"); assert!( labelled, "palette entry must use the 'Agent Dashboard' label" ); } #[test] fn palette_tools_section_routes_each_tab_to_itself() { use crate::views::extensions_modal::ExtensionsTab; let entries = default_palette_entries(); for (label, expected) in [ ("Hooks", ExtensionsTab::Hooks), ("Plugins", ExtensionsTab::Plugins), ("Skills", ExtensionsTab::Skills), ("MCP Servers", ExtensionsTab::McpServers), ] { let entry = entries .iter() .find(|e| e.label == label) .unwrap_or_else(|| panic!("Tools entry {label:?} missing from palette")); assert!( matches!(& entry.command, PaletteCommand::OpenExtensionsTab(t) if * t == expected,), "Tools entry {label:?} dispatches to the wrong tab", ); } } #[test] fn howto_list_modal_opens_on_first_guide() { let modal = howto_list_modal(None); let ActiveModal::DocPicker { state, entries, .. } = modal else { panic!("howto_list_modal should build a DocPicker"); }; assert!(!state.search_active, "how-to picker must open list-focused"); assert_eq!(state.selected, 0); assert_eq!( entries.first().map(|e| e.title.as_str()), Some("Getting Started") ); } } #[cfg(test)] mod doc_picker_tip_tests { use super::{ ActiveModal, DOCS_USER_GUIDE_REL, fit_docs_ask_kigi_tip, howto_list_modal, render_doc_picker_overlay, }; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use unicode_width::UnicodeWidthStr; #[test] fn fit_docs_tip_prefers_path_and_never_overflows() { let path = crate::util::display_user_kigi_path(DOCS_USER_GUIDE_REL); let long = format!("Tip · Ask Kigi about the docs ({path}) — e.g. \"how do I set up MCP?\""); let short = format!("Tip · Ask Kigi about the docs · {path}"); let path_only = format!("Tip · {path}"); assert_eq!(fit_docs_ask_kigi_tip(&path, long.width()), long); assert_eq!(fit_docs_ask_kigi_tip(&path, short.width()), short); assert_eq!(fit_docs_ask_kigi_tip(&path, path_only.width()), path_only); for w in [5usize, 12, 20, 30] { let line = fit_docs_ask_kigi_tip(&path, w); assert!(line.width() <= w, "overflow at {w}: {line:?}"); } } #[test] fn doc_picker_renders_tip_with_path() { let area = Rect { x: 0, y: 0, width: 120, height: 40, }; let mut modal = howto_list_modal(None); let ActiveModal::DocPicker { entries, state, window, .. } = &mut modal else { panic!("expected DocPicker"); }; let theme = crate::theme::Theme::current(); let mut buf = Buffer::empty(area); render_doc_picker_overlay(&mut buf, area, window, entries, state, false, &theme); let mut all = String::new(); for y in 0..area.height { for x in 0..area.width { if let Some(cell) = buf.cell((x, y)) { all.push_str(cell.symbol()); } } all.push('\n'); } assert!( all.contains("Tip") && all.contains("Ask Kigi"), "missing tip footer:\n{all}" ); assert!( all.contains("docs/user-guide") || all.contains("docs\\user-guide"), "missing docs path:\n{all}" ); } }