//! 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; } } /// 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 history_file: Option, } const COMMANDS: &[&str] = &[ "/help", "/quit", "/clear", "/lesson", "/login", "/login zen", "/login openai", "/edit", "/mcp add", "/model", "/model ls", "/model add", "/workflow", "/workflow run", "/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, 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; } /// 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_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, placing it /// in the buffer and closing the dropdown. /// /// Return: `true` if a candidate was selected, `false` if none existed. pub fn select_autocomplete(&mut self) -> bool { if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) { self.buffer = candidate.clone(); self.cursor = self.buffer.len(); self.close_autocomplete(); true } else { false } } /// 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, } 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(), } } 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 } }