//! TUI-perspective application state: `AppStateRest` and all the types it //! owns. This is the single source-of-truth struct for the TUI interface, //! mutated from `action::apply_action` and read by `view/` every render frame. //! //! # Organisation //! //! ```text //! state/ //! ├── mod.rs — AppStateRest (the central struct) + re-exports //! ├── input.rs — InputState, AutocompleteKind //! ├── transcript.rs — TranscriptCache, ChatMessageDisplay //! ├── scroll.rs — ScrollState //! ├── misc.rs — MiscState, EditorState, Overlay //! ├── workflow.rs — SimpleAgent, AgentState, SimpleWorkflowEngine //! └── helpers.rs — Standalone functions operating on AppStateRest //! ``` //! //! # Flow //! Construction in `run.rs::create_local_session` \u{2192} mutated by //! `action::apply_action` \u{2192} read-only in every `view/*::draw*` function. use std::collections::VecDeque; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use tracing::warn; use ratatui::text::Line; use zesdex_domain::cms::{AppConfig, Settings}; use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent}; pub mod helpers; pub mod input; pub mod misc; pub mod scroll; pub mod transcript; pub mod workflow; // Re-export all public types from sub-modules at the `state` level so // consumers that previously used `crate::state::InputState` etc. still work. pub use input::{AutocompleteKind, InputState}; pub use misc::{EditorState, MiscState, Overlay}; pub use scroll::ScrollState; pub use transcript::{ChatMessageDisplay, TranscriptCache}; pub use workflow::{AgentState, SimpleAgent, SimpleWorkflowEngine}; // Re-export the most commonly used helpers at the `state` level. pub use helpers::{ count_tokens, current_effort, cycle_effort, cycle_selected_index, get_learning_items, resolve_context_window, rewind_count, LearningItem, EFFORT_LEVELS, }; // --------------------------------------------------------------------------- // AppStateRest — the single source-of-truth TUI state // --------------------------------------------------------------------------- /// The single source-of-truth state struct for the TUI interface. /// /// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`). /// Read-only from every `view/*` render function. #[derive(Clone)] pub struct AppStateRest { /// Persistent user settings. pub settings: Settings, /// Per-project app configuration. pub app_config: AppConfig, /// Absolute paths to each open workspace root directory. pub workspace_roots: Vec, /// Unique session identifier. pub session_id: String, /// Path to the session's data directory. pub session_dir: PathBuf, /// Path to the session memory directory. pub memory_dir: PathBuf, /// Path to the git worktrees directory. pub worktrees_dir: PathBuf, /// Shared async cache of directory listings. pub dir_cache: Arc>, /// Shared workspace file-path index for `@file` mention autocomplete. pub mention_index: MentionIndex, /// Optional per-session runtime state. pub session_runtime: Option, /// Ring buffer of recent chat messages for the transcript pane. pub transcript_cache: TranscriptCache, /// Viewport scroll offset tracker. pub scroll: ScrollState, /// Chat input buffer, cursor, history, and autocomplete. pub input: InputState, /// Miscellaneous state: overlay, toasts, flags, editor, tick. pub misc: MiscState, /// Queue of events emitted by the running agent turn. pub turn_events: Arc>>, /// Whether an agent turn is currently in flight. /// Uses AtomicBool for lock-free check from render loop. pub turn_in_flight_flag: Arc, /// Cached display lines for the chat transcript panel. pub display_lines_cache: Vec>, /// Cached token count for the current message history. pub cached_token_count: usize, /// Whether the token count cache is stale and needs recalculation. pub token_count_dirty: bool, /// Terminal width at the time of the last display_lines_cache rebuild. pub last_render_width: u16, /// Number of messages that were in the cache when it was last built. pub cached_msg_count: usize, /// Cache index where the last message's rendered lines begin. Used to /// splice streaming updates (only re-render the trailing message). pub cached_last_start: usize, /// Content+reasoning byte length of the last message when it was last /// rendered. Guards the streaming branch from re-rendering on ticks /// where no new token arrived (spinner-only frames). pub cached_last_len: usize, /// Terminal width at the time of the last full cache build. pub render_width_at_cache: u16, /// Atomic flag set when the user aborts the current turn. pub abort_flag: Arc, /// Simplified workflow engine state for display. pub workflow_engine: SimpleWorkflowEngine, /// Whether the state has been modified since the last render sweep. pub dirty: bool, /// Whether the application has been requested to quit. pub quit: bool, /// Cached help text content. pub help_text: &'static str, } /// Default help text shown in the Help overlay. pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI \u{2014} Keyboard Shortcuts \u{2500}\u{2500}\u{2500} General \u{2500}\u{2500}\u{2500} Ctrl+C Quit confirm Ctrl+D Close overlay Ctrl+Y Copy last assistant message Esc Abort turn / Close overlay Tab Autocomplete \u{2500}\u{2500}\u{2500} Navigation \u{2500}\u{2500}\u{2500} \u{2191} / \u{2193} History browse / Overlay navigate Ctrl+\u{2191}/\u{2193} Scroll transcript PgUp / PgDown Scroll transcript Enter Submit / Select autocomplete \u{2500}\u{2500}\u{2500} Overlays \u{2500}\u{2500}\u{2500} /help Show this help /settings Open settings overlay /todo Open tasks (todo) overlay /usage Open usage statistics /bash Open bash jobs overlay /mcp Open MCP server management /model Open model selector /compact Compact conversation /clear Clear transcript /rewind Rewind conversation history \u{2500}\u{2500}\u{2500} Editor Mode \u{2500}\u{2500}\u{2500} /edit Open file for inline editing Ctrl+S Save changes Esc Dismiss editor "#; impl Default for AppStateRest { fn default() -> Self { AppStateRest { settings: Settings::default(), app_config: AppConfig::default(), workspace_roots: Vec::new(), session_id: String::new(), session_dir: PathBuf::new(), memory_dir: PathBuf::new(), worktrees_dir: PathBuf::new(), dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())), mention_index: MentionIndex::new(), session_runtime: None, transcript_cache: TranscriptCache::new(200), scroll: ScrollState::new(), input: InputState::new(), misc: MiscState::new(), turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())), turn_in_flight_flag: Arc::new(AtomicBool::new(false)), abort_flag: Arc::new(AtomicBool::new(false)), workflow_engine: SimpleWorkflowEngine::new(), dirty: true, quit: false, help_text: DEFAULT_HELP_TEXT, display_lines_cache: Vec::new(), cached_token_count: 0, token_count_dirty: true, last_render_width: 0, cached_msg_count: 0, cached_last_start: 0, cached_last_len: 0, render_width_at_cache: 0, } } } impl AppStateRest { /// Construct initial TUI state. pub fn new( workspace_roots: Vec, session_dir: &std::path::Path, memory_dir: PathBuf, ) -> Self { let settings = Settings::default(); let app_config = AppConfig::default(); let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees"); let session_id = session_dir.file_name().map_or_else( || { warn!("[state] session_dir has no file_name, using empty session_id"); String::new() }, |n| n.to_string_lossy().to_string(), ); AppStateRest { settings, app_config, workspace_roots, session_id, session_dir: session_dir.to_path_buf(), memory_dir: memory_dir.clone(), worktrees_dir, turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())), turn_in_flight_flag: Arc::new(AtomicBool::new(false)), abort_flag: Arc::new(AtomicBool::new(false)), dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())), mention_index: MentionIndex::new(), session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())), workflow_engine: SimpleWorkflowEngine::new(), transcript_cache: TranscriptCache::new(200), scroll: ScrollState::new(), input: InputState::new(), misc: MiscState::new(), dirty: true, quit: false, help_text: DEFAULT_HELP_TEXT, display_lines_cache: Vec::new(), cached_token_count: 0, token_count_dirty: true, last_render_width: 0, cached_msg_count: 0, cached_last_start: 0, cached_last_len: 0, render_width_at_cache: 0, } } /// Whether an agent turn is currently running. /// Uses lock-free AtomicBool load \u{2014} safe to call every render frame. pub fn turn_in_flight(&self) -> bool { self.turn_in_flight_flag.load(Ordering::Relaxed) } /// Append a message to the transcript. /// Eviction is O(1) via VecDeque::pop_front. pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { self.transcript_cache.push(msg); self.token_count_dirty = true; self.dirty = true; } /// Append text to the last assistant message in the transcript, if one exists. pub fn append_to_last_transcript(&mut self, text: &str, is_reasoning: bool) { self.transcript_cache.append_to_last(text, is_reasoning); if self.transcript_cache.dirty { self.dirty = true; } } /// Mark the app state as dirty, triggering a TUI re-render. pub fn mark_dirty(&mut self) { self.dirty = true; } /// Queue a toast notification. pub fn push_toast(&mut self, toast: Toast) { self.misc.push_toast(toast); self.mark_dirty(); } /// Push an info toast. pub fn toast_info(&mut self, msg: impl Into) { self.push_toast(Toast::new( zesdex_infrastructure::ToastKind::Info, msg.into(), )); } /// Push a success toast. pub fn toast_success(&mut self, msg: impl Into) { self.push_toast(Toast::new( zesdex_infrastructure::ToastKind::Success, msg.into(), )); } /// Push a warning toast. pub fn toast_warning(&mut self, msg: impl Into) { self.push_toast(Toast::new( zesdex_infrastructure::ToastKind::Warning, msg.into(), )); } /// Push an error toast. pub fn toast_error(&mut self, msg: impl Into) { self.push_toast(Toast::new( zesdex_infrastructure::ToastKind::Error, msg.into(), )); } /// Persist settings to disk. pub fn save_settings(&self) { if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) { let repo = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new( ); use zesdex_domain::SettingsRepository; if let Err(e) = repo.save(&store_dir, &self.settings) { tracing::warn!("Failed to save settings: {e}"); } } } /// Resolve the base directory for session stores. pub fn store_base_dir(&self) -> PathBuf { self.session_dir .parent() .and_then(|p| p.parent()) .map_or_else( || { warn!("[state] no grandparent, using session_dir"); self.session_dir.clone() }, std::path::Path::to_path_buf, ) } }