2026-07-12 11:28:39 +07:00
|
|
|
//! Application-level "miscellaneous" state: scroll, input buffer,
|
|
|
|
|
//! overlay stack, toasts, editor, and autocomplete.
|
2026-07-11 13:16:10 +07:00
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tokio::sync::RwLock;
|
|
|
|
|
use super::types::Overlay;
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// A shared, async-writable cache of directory entries, used to avoid
|
|
|
|
|
/// re-reading a directory every render frame.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct DirCache {
|
|
|
|
|
entries: Arc<RwLock<Vec<PathBuf>>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DirCache {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Create an empty `DirCache`.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn new() -> Self {
|
|
|
|
|
DirCache {
|
|
|
|
|
entries: Arc::new(RwLock::new(Vec::new())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Replace the cached entries (async write).
|
2026-07-11 13:16:10 +07:00
|
|
|
pub async fn set(&self, paths: Vec<PathBuf>) {
|
|
|
|
|
let mut w = self.entries.write().await;
|
|
|
|
|
*w = paths;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Manages the viewport scroll offset.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct ScrollState {
|
|
|
|
|
pub offset: usize,
|
|
|
|
|
pub max_visible: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ScrollState {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn new() -> Self {
|
|
|
|
|
ScrollState {
|
|
|
|
|
offset: 0,
|
|
|
|
|
max_visible: 30,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
|
|
|
|
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
2026-07-12 03:14:52 +07:00
|
|
|
pub fn scroll_up(&mut self, amount: usize) {
|
|
|
|
|
self.offset = self.offset.saturating_add(amount);
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
2026-07-12 03:14:52 +07:00
|
|
|
pub fn scroll_down(&mut self, amount: usize) {
|
|
|
|
|
self.offset = self.offset.saturating_sub(amount);
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Update the maximum number of visible lines.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn set_max_visible(&mut self, max: usize) {
|
|
|
|
|
self.max_visible = max;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// The user's input buffer, cursor position, history, and autocomplete
|
|
|
|
|
/// state for the chat prompt.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct InputState {
|
|
|
|
|
pub buffer: String,
|
|
|
|
|
pub cursor: usize,
|
|
|
|
|
pub history: Vec<String>,
|
|
|
|
|
pub history_idx: Option<usize>,
|
2026-07-11 22:10:17 +07:00
|
|
|
pub autocomplete_prefix: String,
|
|
|
|
|
pub autocomplete_candidates: Vec<String>,
|
|
|
|
|
pub autocomplete_idx: usize,
|
2026-07-12 02:06:46 +07:00
|
|
|
pub autocomplete_visible: bool,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 22:10:17 +07:00
|
|
|
const COMMANDS: &[&str] = &[
|
|
|
|
|
"/help",
|
|
|
|
|
"/quit",
|
|
|
|
|
"/clear",
|
|
|
|
|
"/lesson",
|
|
|
|
|
"/lesson ls",
|
|
|
|
|
"/lesson export",
|
|
|
|
|
"/lesson import",
|
2026-07-12 01:43:57 +07:00
|
|
|
"/lesson accept",
|
|
|
|
|
"/lesson reject",
|
2026-07-11 22:10:17 +07:00
|
|
|
"/login",
|
2026-07-12 01:43:57 +07:00
|
|
|
"/login zen",
|
|
|
|
|
"/login openai",
|
|
|
|
|
"/edit",
|
|
|
|
|
"/mcp add",
|
2026-07-12 02:06:46 +07:00
|
|
|
"/model",
|
|
|
|
|
"/model ls",
|
|
|
|
|
"/model add",
|
2026-07-11 22:10:17 +07:00
|
|
|
];
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
impl InputState {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Create an empty input state with no buffer, no history, and no
|
|
|
|
|
/// autocomplete.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn new() -> Self {
|
|
|
|
|
InputState {
|
|
|
|
|
buffer: String::new(),
|
|
|
|
|
cursor: 0,
|
|
|
|
|
history: Vec::new(),
|
|
|
|
|
history_idx: None,
|
2026-07-11 22:10:17 +07:00
|
|
|
autocomplete_prefix: String::new(),
|
|
|
|
|
autocomplete_candidates: Vec::new(),
|
|
|
|
|
autocomplete_idx: 0,
|
2026-07-12 02:06:46 +07:00
|
|
|
autocomplete_visible: false,
|
2026-07-11 22:10:17 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Hide the autocomplete dropdown and clear its state.
|
2026-07-12 02:06:46 +07:00
|
|
|
pub fn close_autocomplete(&mut self) {
|
|
|
|
|
self.autocomplete_visible = false;
|
|
|
|
|
self.autocomplete_candidates.clear();
|
|
|
|
|
self.autocomplete_prefix.clear();
|
|
|
|
|
self.autocomplete_idx = 0;
|
|
|
|
|
}
|
2026-07-11 22:10:17 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-12 02:06:46 +07:00
|
|
|
pub fn open_autocomplete(&mut self) {
|
|
|
|
|
let trimmed = self.buffer.trim().to_string();
|
|
|
|
|
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
|
|
|
|
self.close_autocomplete();
|
2026-07-11 22:10:17 +07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let prefix = trimmed.to_lowercase();
|
2026-07-12 02:06:46 +07:00
|
|
|
self.autocomplete_candidates = COMMANDS
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|c| c.starts_with(&prefix))
|
|
|
|
|
.map(|c| c.to_string())
|
|
|
|
|
.collect();
|
|
|
|
|
self.autocomplete_prefix = prefix;
|
|
|
|
|
self.autocomplete_idx = 0;
|
|
|
|
|
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
|
|
|
|
}
|
2026-07-11 22:10:17 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Move the autocomplete selection up (forward=false) or down (forward=true).
|
|
|
|
|
/// Wraps around at the boundaries.
|
2026-07-12 02:06:46 +07:00
|
|
|
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;
|
2026-07-11 22:10:17 +07:00
|
|
|
} else {
|
2026-07-12 02:06:46 +07:00
|
|
|
self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 };
|
2026-07-11 22:10:17 +07:00
|
|
|
}
|
2026-07-12 02:06:46 +07:00
|
|
|
}
|
2026-07-11 22:10:17 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-12 02:06:46 +07:00
|
|
|
pub fn select_autocomplete(&mut self) -> bool {
|
2026-07-11 22:10:17 +07:00
|
|
|
if let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx) {
|
|
|
|
|
self.buffer = candidate.clone();
|
|
|
|
|
self.cursor = self.buffer.len();
|
2026-07-12 02:06:46 +07:00
|
|
|
self.close_autocomplete();
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Legacy inline tab-complete — opens the dropdown on first Tab press,
|
|
|
|
|
/// then cycles forward on subsequent presses.
|
2026-07-12 02:06:46 +07:00
|
|
|
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.open_autocomplete();
|
|
|
|
|
} else {
|
|
|
|
|
self.cycle_autocomplete(true);
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Move the cursor left by one character (if not at the start).
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn char_left(&mut self) {
|
|
|
|
|
if self.cursor > 0 {
|
|
|
|
|
self.cursor -= 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Move the cursor right by one character (if not at the end).
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn char_right(&mut self) {
|
|
|
|
|
if self.cursor < self.buffer.len() {
|
|
|
|
|
self.cursor += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Insert a character at the cursor position.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn insert(&mut self, c: char) {
|
|
|
|
|
self.buffer.insert(self.cursor, c);
|
|
|
|
|
self.cursor += 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Delete the character to the left of the cursor (backspace).
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn delete_left(&mut self) {
|
|
|
|
|
if self.cursor > 0 {
|
|
|
|
|
self.cursor -= 1;
|
|
|
|
|
self.buffer.remove(self.cursor);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Delete the character at the cursor position (forward delete).
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn delete_right(&mut self) {
|
|
|
|
|
if self.cursor < self.buffer.len() {
|
|
|
|
|
self.buffer.remove(self.cursor);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Submit the current buffer: push it to history, clear the buffer,
|
|
|
|
|
/// and return the submitted text.
|
|
|
|
|
///
|
|
|
|
|
/// Return: the text that was in the buffer before clearing.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn submit(&mut self) -> String {
|
|
|
|
|
let result = self.buffer.clone();
|
|
|
|
|
if !result.is_empty() {
|
|
|
|
|
self.history.push(result.clone());
|
|
|
|
|
self.history_idx = None;
|
|
|
|
|
}
|
|
|
|
|
self.buffer.clear();
|
|
|
|
|
self.cursor = 0;
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Navigate backward through input history.
|
2026-07-11 13:16:10 +07:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Navigate forward through input history (back toward the newest entry).
|
2026-07-11 13:16:10 +07:00
|
|
|
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 => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// The "miscellaneous" slice of app state: which overlay is showing,
|
|
|
|
|
/// toasts, thinking/connected flags, effort level, editor state, and tick.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct MiscState {
|
|
|
|
|
pub overlay: Overlay,
|
|
|
|
|
pub toasts: Vec<super::types::Toast>,
|
2026-07-11 21:06:22 +07:00
|
|
|
pub last_staleness_sweep_ms: i64,
|
2026-07-11 23:45:13 +07:00
|
|
|
pub thinking: bool,
|
2026-07-12 01:25:52 +07:00
|
|
|
pub effort_level: usize,
|
|
|
|
|
pub selected_index: usize,
|
|
|
|
|
pub editor: Option<super::super::mode::editor::EditorState>,
|
2026-07-12 02:26:53 +07:00
|
|
|
pub api_connected: bool,
|
2026-07-12 03:19:25 +07:00
|
|
|
pub tick_count: u64,
|
2026-07-12 12:43:50 +07:00
|
|
|
pub todo_content: String,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MiscState {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Create a fresh `MiscState` with no overlay, no toasts, and default
|
|
|
|
|
/// effort level 1.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn new() -> Self {
|
|
|
|
|
MiscState {
|
|
|
|
|
overlay: Overlay::None,
|
|
|
|
|
toasts: Vec::new(),
|
2026-07-11 21:06:22 +07:00
|
|
|
last_staleness_sweep_ms: 0,
|
2026-07-11 23:45:13 +07:00
|
|
|
thinking: false,
|
2026-07-12 01:25:52 +07:00
|
|
|
effort_level: 1,
|
|
|
|
|
selected_index: 0,
|
|
|
|
|
editor: None,
|
2026-07-12 02:26:53 +07:00
|
|
|
api_connected: false,
|
2026-07-12 03:19:25 +07:00
|
|
|
tick_count: 0,
|
2026-07-12 12:43:50 +07:00
|
|
|
todo_content: String::new(),
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn push_toast(&mut self, toast: super::types::Toast) {
|
|
|
|
|
self.toasts.push(toast);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
|
|
|
|
|
///
|
|
|
|
|
/// Return: the expired toasts (after removal).
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
|
|
|
|
|
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
|
|
|
|
|
self.toasts.retain(|t| !t.expired(now_ms));
|
|
|
|
|
expired
|
|
|
|
|
}
|
|
|
|
|
}
|