//! Application-level "miscellaneous" state: scroll, input buffer, //! overlay stack, toasts, editor, and autocomplete. use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; use super::types::Overlay; /// A shared, async-writable cache of directory entries, used to avoid /// re-reading a directory every render frame. #[derive(Clone)] pub struct DirCache { entries: Arc>>, } impl DirCache { /// Create an empty `DirCache`. pub fn new() -> Self { DirCache { entries: Arc::new(RwLock::new(Vec::new())), } } /// Replace the cached entries (async write). pub async fn set(&self, paths: Vec) { let mut w = self.entries.write().await; *w = paths; } } /// A shared, whole-workspace file-path index used for `@file` mention /// autocomplete. Built once by a background thread at startup (see /// `AppStateRest::new`) and incrementally appended to when tools create /// new files (see `tool/fs/write.rs`). #[derive(Clone)] pub struct MentionIndex { entries: Arc>>, } impl MentionIndex { /// Create an empty `MentionIndex`. pub fn new() -> Self { MentionIndex { entries: Arc::new(std::sync::RwLock::new(Vec::new())), } } /// Replace the indexed paths (used by the startup background walk). pub fn set(&self, paths: Vec) { if let Ok(mut w) = self.entries.write() { *w = paths; } } /// Append a single newly created file's path (used by the `write` tool). pub fn push(&self, path: String) { if let Ok(mut w) = self.entries.write() { w.push(path); } } /// Take a snapshot of the current indexed paths for fuzzy matching. pub fn snapshot(&self) -> Vec { self.entries.read().map(|r| r.clone()).unwrap_or_default() } } /// Which source populated the autocomplete dropdown, since selecting a /// candidate is spliced into the buffer differently for each. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AutocompleteKind { Command, FileMention, } /// Manages the viewport scroll offset. #[derive(Debug, Clone)] pub struct ScrollState { pub offset: usize, pub max_visible: usize, } impl ScrollState { /// Create a `ScrollState` with zero offset and 30 rows visible. pub fn new() -> Self { ScrollState { offset: 0, max_visible: 30, } } /// Scroll the viewport up by `amount` lines (increasing the offset). /// Scroll the viewport up by `amount` lines (increasing the offset). pub fn scroll_up(&mut self, amount: usize) { self.offset = self.offset.saturating_add(amount); } /// Scroll the viewport down by `amount` lines (decreasing the offset). pub fn scroll_down(&mut self, amount: usize) { self.offset = self.offset.saturating_sub(amount); } /// Update the maximum number of visible lines. pub fn set_max_visible(&mut self, max: usize) { self.max_visible = max; } } /// The user's input buffer, cursor position, history, and autocomplete /// state for the chat prompt. #[derive(Debug, Clone)] pub struct InputState { pub buffer: String, pub cursor: usize, pub history: Vec, pub history_idx: Option, pub autocomplete_prefix: String, pub autocomplete_candidates: Vec, pub autocomplete_idx: usize, pub autocomplete_visible: bool, pub autocomplete_kind: AutocompleteKind, pub mention_start: usize, pub history_file: Option, } const COMMANDS: &[&str] = &[ "/help", "/quit", "/clear", "/login", "/login zen", "/login openai", "/edit", "/mcp add", "/model", "/model ls", "/model add", "/todo", "/usage", "/compact", ]; impl InputState { /// Create an empty input state with no buffer, no history, and no /// autocomplete. pub fn new() -> Self { InputState { buffer: String::new(), cursor: 0, history: Vec::new(), history_idx: None, autocomplete_prefix: String::new(), autocomplete_candidates: Vec::new(), autocomplete_idx: 0, autocomplete_visible: false, autocomplete_kind: AutocompleteKind::Command, mention_start: 0, history_file: None, } } /// Hide the autocomplete dropdown and clear its state. pub fn close_autocomplete(&mut self) { self.autocomplete_visible = false; self.autocomplete_candidates.clear(); self.autocomplete_prefix.clear(); self.autocomplete_idx = 0; self.autocomplete_kind = AutocompleteKind::Command; self.mention_start = 0; } /// Open or refresh the autocomplete dropdown by filtering `COMMANDS` /// against the current buffer prefix. /// /// Flow: if buffer is empty or doesn't start with `/`, close and return /// → filter `COMMANDS` by prefix match → store candidates → set /// `autocomplete_visible` if any candidates found. pub fn open_autocomplete(&mut self) { let trimmed = self.buffer.trim().to_string(); if trimmed.is_empty() || !trimmed.starts_with('/') { self.close_autocomplete(); return; } let prefix = trimmed.to_lowercase(); self.autocomplete_candidates = COMMANDS .iter() .filter(|c| c.starts_with(&prefix)) .map(std::string::ToString::to_string) .collect(); self.autocomplete_prefix = prefix; self.autocomplete_kind = AutocompleteKind::Command; self.autocomplete_idx = 0; self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); } /// Find the `@mention` token (if any) immediately before the cursor. /// /// Flow: find the nearest `@` before the cursor → if there's whitespace /// between that `@` and the cursor, no trigger → the `@` only counts as /// a trigger if it's at buffer start or immediately preceded by /// whitespace (so `foo@bar` mid-word never triggers). /// /// Return: `Some((byte offset of '@', query text between '@' and cursor))` /// or `None` if the cursor isn't inside a mention token. pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> { let before_cursor = &self.buffer[..self.cursor]; let at_pos = before_cursor.rfind('@')?; let between = &before_cursor[at_pos + 1..]; if between.chars().any(char::is_whitespace) { return None; } let boundary_ok = at_pos == 0 || before_cursor[..at_pos].chars().next_back().is_some_and(char::is_whitespace); if !boundary_ok { return None; } Some((at_pos, between.to_string())) } /// Open or refresh the `@file` mention dropdown from `files`, fuzzy-matched /// against the mention query at the cursor. /// /// Flow: `mention_query_at_cursor` finds the trigger `@` and query text → /// if none, close and return → otherwise fuzzy-match `query` against /// `files` via `nucleo-matcher`, keep the top 10 by score. pub fn open_mention_autocomplete(&mut self, files: &[String]) { let Some((start, query)) = self.mention_query_at_cursor() else { self.close_autocomplete(); return; }; use nucleo_matcher::{Config, Matcher}; use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart); let matches = pattern.match_list(files.iter(), &mut matcher); self.autocomplete_candidates = matches.into_iter().take(10).map(|(f, _)| f.clone()).collect(); self.autocomplete_kind = AutocompleteKind::FileMention; self.mention_start = start; self.autocomplete_idx = 0; self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); } /// Move the autocomplete selection up (forward=false) or down (forward=true). /// Wraps around at the boundaries. pub fn cycle_autocomplete(&mut self, forward: bool) { let n = self.autocomplete_candidates.len(); if n == 0 { return; } if forward { self.autocomplete_idx = (self.autocomplete_idx + 1) % n; } else { self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 }; } } /// Accept the currently selected autocomplete candidate. /// /// `Command` candidates replace the whole buffer; `FileMention` /// candidates splice `@path ` in at the mention's start position so the /// rest of the sentence around it is preserved. /// /// Return: `true` if a candidate was selected, `false` if none existed. pub fn select_autocomplete(&mut self) -> bool { let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else { return false; }; match self.autocomplete_kind { AutocompleteKind::Command => { self.buffer = candidate; self.cursor = self.buffer.len(); } AutocompleteKind::FileMention => { // Cursor movement (Left/Right) does not close the dropdown, so // by the time Enter is pressed `mention_start` may no longer // describe a valid range against the current cursor/buffer // (e.g. the cursor moved left past the '@'). Splicing on a // stale range would panic (`start > end`) or, even when it // doesn't panic, produce a nonsensical replacement. Treat a // stale mention context the same as "nothing selected". if self.cursor < self.mention_start || self.mention_start > self.buffer.len() { self.close_autocomplete(); return false; } let replacement = format!("@{candidate} "); self.buffer.replace_range(self.mention_start..self.cursor, &replacement); self.cursor = self.mention_start + replacement.len(); } } self.close_autocomplete(); true } /// Legacy inline tab-complete — opens the dropdown on first Tab press, /// then cycles forward on subsequent presses. pub fn tab_complete(&mut self) { // Legacy inline tab-complete — used as a fallback when the dropdown // isn't visible yet. Opens the dropdown on the first Tab press. if self.autocomplete_visible { self.cycle_autocomplete(true); } else { self.open_autocomplete(); } } /// Move the cursor left by one character (if not at the start). pub fn char_left(&mut self) { if self.cursor > 0 { self.cursor -= 1; } } /// Move the cursor right by one character (if not at the end). pub fn char_right(&mut self) { if self.cursor < self.buffer.len() { self.cursor += 1; } } /// Insert a character at the cursor position. pub fn insert(&mut self, c: char) { self.buffer.insert(self.cursor, c); self.cursor += 1; } /// Delete the character to the left of the cursor (backspace). pub fn delete_left(&mut self) { if self.cursor > 0 { self.cursor -= 1; self.buffer.remove(self.cursor); } } /// Delete the character at the cursor position (forward delete). pub fn delete_right(&mut self) { if self.cursor < self.buffer.len() { self.buffer.remove(self.cursor); } } pub fn submit(&mut self) -> String { let result = self.buffer.clone(); if !result.is_empty() { if self.history.last() != Some(&result) { self.history.push(result.clone()); if let Some(ref path) = self.history_file { if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) .append(true) .open(path) { use std::io::Write; let _ = writeln!(file, "{result}"); } } } self.history_idx = None; } self.buffer.clear(); self.cursor = 0; result } /// Navigate backward through input history. pub fn history_up(&mut self) { if self.history.is_empty() { return; } let idx = match self.history_idx { Some(i) if i > 0 => i - 1, None => self.history.len() - 1, Some(_) => return, }; self.history_idx = Some(idx); self.buffer = self.history[idx].clone(); self.cursor = self.buffer.len(); } /// Navigate forward through input history (back toward the newest entry). pub fn history_down(&mut self) { match self.history_idx { Some(i) if i < self.history.len() - 1 => { let idx = i + 1; self.history_idx = Some(idx); self.buffer = self.history[idx].clone(); self.cursor = self.buffer.len(); } Some(_) => { self.history_idx = None; self.buffer.clear(); self.cursor = 0; } None => {} } } } /// The "miscellaneous" slice of app state: which overlay is showing, /// toasts, thinking/connected flags, effort level, editor state, and tick. #[derive(Debug, Clone)] pub struct MiscState { pub overlay: Overlay, pub toasts: Vec, pub last_staleness_sweep_ms: i64, pub thinking: bool, pub effort_level: usize, pub selected_index: usize, pub editor: Option, pub api_connected: bool, #[allow(dead_code)] pub api_context_length: Option, pub tick_count: u64, pub todo_content: String, pub lesson_running: bool, pub pending_clipboard_copy: Option, } impl MiscState { /// Create a fresh `MiscState` with no overlay, no toasts, and default /// effort level 1. pub fn new() -> Self { MiscState { overlay: Overlay::None, toasts: Vec::new(), last_staleness_sweep_ms: 0, thinking: false, effort_level: 1, selected_index: 0, editor: None, api_connected: false, api_context_length: None, tick_count: 0, todo_content: String::new(), lesson_running: false, pending_clipboard_copy: None, } } pub fn push_toast(&mut self, toast: super::types::Toast) { self.toasts.push(toast); } /// Remove and return all toasts whose lifetime has expired at `now_ms`. /// /// Return: the expired toasts (after removal). pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec { let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect(); self.toasts.retain(|t| !t.expired(now_ms)); expired } } #[cfg(test)] mod tests { use super::*; fn input_with(buffer: &str, cursor: usize) -> InputState { let mut input = InputState::new(); input.buffer = buffer.to_string(); input.cursor = cursor; input } #[test] fn mention_at_buffer_start_triggers() { let input = input_with("@mai", 4); assert_eq!(input.mention_query_at_cursor(), Some((0, "mai".to_string()))); } #[test] fn mention_after_space_mid_sentence_triggers() { let input = input_with("look at @read", 13); assert_eq!(input.mention_query_at_cursor(), Some((8, "read".to_string()))); } #[test] fn mid_word_at_does_not_trigger() { let input = input_with("foo@bar", 7); assert_eq!(input.mention_query_at_cursor(), None); } #[test] fn whitespace_between_at_and_cursor_does_not_trigger() { let input = input_with("@foo bar", 8); assert_eq!(input.mention_query_at_cursor(), None); } #[test] fn select_file_mention_splices_into_buffer() { let mut input = input_with("look at @rea and fix it", 12); input.autocomplete_candidates = vec!["src/main.rs".to_string()]; input.autocomplete_idx = 0; input.autocomplete_kind = AutocompleteKind::FileMention; input.mention_start = 8; assert!(input.select_autocomplete()); assert_eq!(input.buffer, "look at @src/main.rs and fix it"); assert_eq!(input.cursor, 8 + "@src/main.rs ".len()); } #[test] fn select_file_mention_with_stale_cursor_before_mention_start_does_not_panic() { // Simulates: user typed "foo @rea" (mention_start = 4, cursor = 8, // dropdown open), then pressed Left 5 times without closing the // dropdown, moving the cursor to byte 3 (before the '@'). Selecting // now must not panic on `replace_range(4..3, ...)`. let mut input = input_with("foo @rea", 3); input.autocomplete_candidates = vec!["src/main.rs".to_string()]; input.autocomplete_idx = 0; input.autocomplete_kind = AutocompleteKind::FileMention; input.mention_start = 4; assert!(!input.select_autocomplete()); assert!(!input.autocomplete_visible); } #[test] fn select_command_still_replaces_whole_buffer() { let mut input = input_with("/mo", 3); input.autocomplete_candidates = vec!["/model".to_string()]; input.autocomplete_idx = 0; input.autocomplete_kind = AutocompleteKind::Command; assert!(input.select_autocomplete()); assert_eq!(input.buffer, "/model"); assert_eq!(input.cursor, "/model".len()); } #[test] fn misc_state_starts_with_no_pending_clipboard_copy() { let misc = MiscState::new(); assert!(misc.pending_clipboard_copy.is_none()); } }