The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
1340 lines
47 KiB
Rust
1340 lines
47 KiB
Rust
//! 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<R>` 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<R> {
|
|
/// Available options (key → result). Labels are derived from `R` at render time.
|
|
pub options: Vec<ModalOption<R>>,
|
|
}
|
|
/// One option in a modal dialog.
|
|
pub struct ModalOption<R> {
|
|
/// 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<R> ModalConfirmation<R> {
|
|
/// 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<EditConfirmResult> {
|
|
/// 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<ResetSettingsResult> {
|
|
/// 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<PaletteSnapshot>) -> 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<R>` 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<EditConfirmResult>,
|
|
/// Where to switch focus after confirmation (if not Cancel).
|
|
pending_target: super::agent::ActivePane,
|
|
},
|
|
/// Command palette (Ctrl+P).
|
|
CommandPalette {
|
|
entries: Vec<PaletteEntry>,
|
|
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<crate::slash::command::ArgItem>,
|
|
/// Original items from suggest_args() (source for filtering).
|
|
original_items: Vec<crate::slash::command::ArgItem>,
|
|
/// Unified picker state.
|
|
state: crate::views::picker::PickerState,
|
|
/// Previous command palette state (if opened from palette). Restored on Esc.
|
|
previous_palette: Option<PaletteSnapshot>,
|
|
/// 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<Vec<crate::app::app_view::SessionPickerEntry>>,
|
|
/// 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<PaletteSnapshot>,
|
|
/// Shared modal window chrome state.
|
|
window: ModalWindowState,
|
|
/// Content-based (deep search) results from ACP session search.
|
|
content_results: Option<Vec<kigi_shell::extensions::session_search::SearchSessionHit>>,
|
|
/// 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<String>,
|
|
/// 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<DocEntry>,
|
|
state: crate::views::picker::PickerState,
|
|
/// Previous command palette state (if opened from palette). Restored on Esc.
|
|
previous_palette: Option<PaletteSnapshot>,
|
|
/// 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<ratatui::text::Line<'static>>)>,
|
|
/// 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<PaletteSnapshot>,
|
|
/// 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<crate::views::shortcuts_help::ShortcutsHelpEntry>,
|
|
/// 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<usize>,
|
|
/// Rows whose inline help is expanded under the list (pattern A).
|
|
expanded_ids: std::collections::HashSet<crate::views::shortcuts_help::ExpandKey>,
|
|
/// Browse list vs in-modal detail page (pattern B).
|
|
mode: crate::views::shortcuts_help::ShortcutsHelpMode,
|
|
},
|
|
/// Memory browser modal (/memory).
|
|
MemoryBrowser {
|
|
state: Box<crate::views::memory_modal::MemoryModalState>,
|
|
},
|
|
/// Settings modal (F2, /settings, palette). Boxed — large state.
|
|
Settings {
|
|
state: Box<crate::views::settings_modal::SettingsModalState>,
|
|
},
|
|
/// 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<ResetSettingsResult>,
|
|
/// Setting key being reset.
|
|
key: crate::settings::SettingKey,
|
|
/// Preserved settings state, restored by both choice branches.
|
|
settings_state: Box<crate::views::settings_modal::SettingsModalState>,
|
|
},
|
|
/// 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<String>,
|
|
showing_enhanced: bool,
|
|
scroll: u16,
|
|
window: ModalWindowState,
|
|
cached_lines: Option<(u16, Vec<ratatui::text::Line<'static>>)>,
|
|
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<PaletteEntry>,
|
|
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<PaletteEntry> {
|
|
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<PaletteEntry> {
|
|
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<PaletteEntry> = 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<String> {
|
|
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<String> {
|
|
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 `(<default>)` 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<ModalButtonHit>,
|
|
}
|
|
/// 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<char>,
|
|
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<Rect>,
|
|
) {
|
|
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<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,
|
|
},
|
|
];
|
|
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<Vec<&str>> = 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<PickerEntry<'_>> = 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<ratatui::text::Line<'static>>)>,
|
|
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<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<ratatui::text::Line> = 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}"
|
|
);
|
|
}
|
|
}
|