//! Top-level mutable application state (`AppStateRest`) and the transcript //! display type it owns. //! //! `AppStateRest` is the single source-of-truth struct mutated in-place from //! `actions/mod.rs` and `controller/input.rs`; every other module reads it. use std::collections::VecDeque; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::sync::RwLock; use super::input::InputState; use super::misc::{DirCache, MentionIndex, MiscState}; use super::scroll::ScrollState; use super::runtime::{SessionRuntime, TurnEvent}; use super::types::{Origin, Toast, TranscriptCache}; use crate::app::lsp::LspManager; use crate::app::mcp::manager::McpManager; use crate::app::workflow::engine::WorkflowEngine; use zesdex_cms::domain::app_config::AppConfig; use zesdex_cms::domain::edit_log::EditLog; use zesdex_cms::domain::repository::AppConfigRepository; use zesdex_cms::domain::repository::EditLogRepository; use zesdex_cms::domain::repository::SettingsRepository; use zesdex_cms::domain::settings::Settings; use zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository; use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository; use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository; /// A single transcript entry rendered in the TUI chat pane. #[derive(Debug, Clone, PartialEq)] pub struct ChatMessageDisplay { pub role: crate::dto::chat::message::Role, pub content: String, pub timestamp: i64, } impl ChatMessageDisplay { /// Build a display entry, stamping it with the current time. pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self { ChatMessageDisplay { role, content, timestamp: chrono::Utc::now().timestamp_millis(), } } } /// The single source-of-truth state struct for the entire application. /// /// Mutated in-place from two locations: `actions/mod.rs` (`apply_action`) /// and `controller/input.rs` (key event handlers). Read-only from every /// other module. #[derive(Clone)] pub struct AppStateRest { pub settings: Settings, pub app_config: AppConfig, pub workspace_roots: Vec, pub session_id: String, pub session_dir: PathBuf, pub memory_dir: PathBuf, pub worktrees_dir: PathBuf, pub dir_cache: Arc>, pub mention_index: MentionIndex, pub edit_log: EditLog, pub session_runtime: Option, pub sessions: Vec, pub transcript_cache: TranscriptCache, pub scroll: ScrollState, pub input: InputState, pub misc: MiscState, pub turn_events: Arc>>, pub turn_in_flight: Arc>, pub abort_flag: Arc, pub workflow_engine: WorkflowEngine, pub mcp_manager: McpManager, pub lsp_manager: Arc>, /// Shared queue: provisioner thread pushes status updates, /// drained into toasts on each Tick. pub lsp_provision_msgs: Arc>>, pub dirty: bool, pub quit: bool, } impl AppStateRest { /// Construct the initial application state for a session. /// /// Flow: load settings/config -> derive download/worktree dirs from /// `memory_dir`'s parent -> derive `session_id` from the session dir's /// file name -> build the sub-state structs. /// /// Why: falls back to `memory_dir` itself (with a warning) when it has /// no parent, and to an empty session id when the dir name can't be /// read, so construction never fails. pub fn new( workspace_roots: Vec, session_dir: &std::path::Path, memory_dir: PathBuf, ) -> Self { let store_base_dir = zesdex_entities::domain::common::store::Store::new().base_dir; let settings = JsonSettingsRepository::new() .load(&store_base_dir) .unwrap_or_default(); let app_config = JsonAppConfigRepository::new() .load(&store_base_dir) .unwrap_or_default(); let worktrees_dir = memory_dir .parent() .unwrap_or_else(|| { tracing::warn!( "[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display() ); &memory_dir }) .join("worktrees"); let dir_cache = DirCache::new(); let session_id = session_dir.file_name().map_or_else( || { tracing::warn!( "[state] session_dir has no file_name component, using empty session_id" ); String::new() }, |n| n.to_string_lossy().to_string(), ); let mut state = AppStateRest { settings, app_config, workspace_roots, session_id, session_dir: session_dir.to_path_buf(), memory_dir, worktrees_dir, turn_events: Arc::new(Mutex::new(VecDeque::new())), turn_in_flight: Arc::new(Mutex::new(false)), abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), dir_cache: Arc::new(RwLock::new(dir_cache)), mention_index: MentionIndex::new(), edit_log: JsonlEditLogRepository::new() .open(session_dir) .unwrap_or_else(|e| { tracing::warn!( "[state] failed to open edit log at '{}': {e}", session_dir.display() ); EditLog::new() }), session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())), workflow_engine: WorkflowEngine::new(), mcp_manager: McpManager::new(), lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())), lsp_manager: Arc::new(Mutex::new(LspManager::new())), sessions: Vec::new(), transcript_cache: TranscriptCache::new(200), scroll: ScrollState::new(), input: InputState::new(), misc: MiscState::new(), dirty: true, quit: false, }; // Load project-specific history let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir); if let Some(root) = state.workspace_roots.first() { if let Ok(abs_root) = std::fs::canonicalize(root) { use sha2::Digest; let mut hasher = sha2::Sha256::new(); hasher.update(abs_root.to_string_lossy().as_bytes()); let hash_hex = hex::encode(hasher.finalize()); let folder_name = abs_root .file_name() .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string()); let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]); let history_dir = base_dir.join("history"); let _ = std::fs::create_dir_all(&history_dir); let history_file = history_dir.join(history_filename); if let Ok(content) = std::fs::read_to_string(&history_file) { let history: Vec = content .lines() .map(std::string::ToString::to_string) .filter(|s| !s.is_empty()) .collect(); state.input.history = history; } state.input.history_file = Some(history_file); } } // Fire-and-forget background LSP provisioning. // // Flow: spawn OS thread -> provision_all() probes/installs every // supported language server -> auto_connect() attaches whichever // ones ended up available to the shared `lsp_manager` -> log a line // per connected server and per failure. // // Why a raw thread and not a tokio task: this runs before the async // runtime's executor may be fully set up for this state, and the // provisioning work (shelling out to package managers, network // downloads) is blocking I/O; a dedicated thread keeps it off any // async executor entirely. It is deliberately not joined -- startup // must not block on language server installation, and failures are // logged rather than surfaced, since editing still works without LSP. if state.settings.flags.lsp_auto_provision { let lsp_mgr = state.lsp_manager.clone(); let msg_queue = state.lsp_provision_msgs.clone(); std::thread::spawn(move || { use crate::app::lsp::provisioner::{self, ProvisionResult}; fn push_msg(q: &Arc>>, msg: &str) { if let Ok(mut q) = q.lock() { q.push_back(msg.to_string()); } } // Wrap the msg_queue in a static-lifetime closure for use as ProgressFn. let progress: provisioner::ProgressFn = Some(&|msg: &str| push_msg(&msg_queue, msg)); let report = |msg: &str| { if let Some(f) = &progress { f(msg); } }; report("LSP: provisioning servers..."); let results = provisioner::provision_all_with_progress(progress); report("LSP: connecting servers..."); let connected = provisioner::auto_connect(&lsp_mgr, &results); for name in &connected { tracing::info!("LSP: {} connected", name); let m = format!("LSP: {name} connected ✓"); push_msg(&msg_queue, &m); } for r in &results { if let ProvisionResult::Failed { language, server_name, reason, .. } = r { tracing::warn!("LSP {} ({}): {}", server_name, language, reason); let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); push_msg(&msg_queue, &m); } } if connected.is_empty() { let m = "LSP: no servers available — install manually or check prerequisites" .to_string(); push_msg(&msg_queue, &m); } else { let m = format!("LSP: {} server(s) connected", connected.len()); push_msg(&msg_queue, &m); } }); } state } /// Spawn the background thread that walks every workspace root and /// populates `mention_index` for `@file` mention autocomplete. /// /// Why a separate method, not called from `new()`: the attach-only /// TUI client also constructs an `AppStateRest` (for local rendering /// state) but never runs tools or `handle_key` locally — it forwards /// keystrokes to the daemon over IPC, which has its own `AppStateRest` /// with its own index. Spawning this walk in the attach client would /// waste a full workspace scan for an index nothing there consumes. /// Callers that DO need the index (single-process mode, the daemon) /// call this explicitly after construction. /// /// Flow: spawn OS thread -> `ignore::Walk` each workspace root, /// collecting file paths (workspace-index-prefixed for roots beyond /// the first, matching `resolve_path`'s `[N]path` convention) -> stop /// once 50,000 entries are collected -> store the result in /// `mention_index`. /// /// Why a raw thread and not a background tokio task: there is no /// persistent async runtime driving the render loop, and this is /// blocking filesystem I/O -- a dedicated thread keeps startup /// non-blocking. Not joined, same rationale as the LSP provisioning /// thread above: a slow/huge repo must not delay the TUI appearing. pub fn spawn_mention_index_build(&self) { let mention_index = self.mention_index.clone(); let roots = self.workspace_roots.clone(); std::thread::spawn(move || { const MAX_MENTION_ENTRIES: usize = 50_000; let mut paths = Vec::new(); 'roots: for (i, root) in roots.iter().enumerate() { for entry in ignore::Walk::new(root).flatten() { if !entry.path().is_file() { continue; } let rel = entry.path().strip_prefix(root).unwrap_or(entry.path()); let rel_str = rel.display().to_string(); let formatted = if i == 0 { rel_str } else { format!("[{i}]{rel_str}") }; paths.push(formatted); if paths.len() >= MAX_MENTION_ENTRIES { break 'roots; } } } mention_index.set(paths); }); } /// Whether an agent turn is currently running. /// /// Return: `false` (and logs a warning) if the mutex is poisoned, rather /// than propagating a panic. pub fn turn_in_flight(&self) -> bool { self.turn_in_flight.lock().map_or_else( |_| { tracing::warn!("[state] turn_in_flight mutex poisoned"); false }, |g| *g, ) } /// Shut down every running LSP server process. /// /// Why: called on app exit so language servers don't linger as orphaned /// processes; silently no-ops if the mutex is poisoned since there is /// nothing more useful to do at shutdown time. pub fn shutdown_lsp(&mut self) { if let Ok(mut mgr) = self.lsp_manager.lock() { mgr.shutdown_all(); } } /// Append a message to the transcript, evicting the oldest entry once /// `max_lines` is exceeded, and mark both the cache and the app dirty. pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { self.transcript_cache.messages.push(msg); if self.transcript_cache.messages.len() > self.transcript_cache.max_lines { self.transcript_cache.messages.remove(0); } self.transcript_cache.dirty = true; self.dirty = true; } /// Mark the app state as dirty, triggering a TUI re-render on the next frame. pub fn mark_dirty(&mut self) { self.dirty = true; } /// Queue a toast notification for display and mark the app dirty. pub fn push_toast(&mut self, toast: Toast) { self.misc.push_toast(toast); self.mark_dirty(); } /// Push an info toast with the given message. pub fn toast_info(&mut self, msg: impl Into) { self.push_toast(Toast::new(super::types::ToastKind::Info, msg.into())); } /// Push a success toast with the given message. pub fn toast_success(&mut self, msg: impl Into) { self.push_toast(Toast::new(super::types::ToastKind::Success, msg.into())); } /// Push a warning toast with the given message. pub fn toast_warning(&mut self, msg: impl Into) { self.push_toast(Toast::new(super::types::ToastKind::Warning, msg.into())); } /// Push an error toast with the given message. pub fn toast_error(&mut self, msg: impl Into) { self.push_toast(Toast::new(super::types::ToastKind::Error, msg.into())); } /// Resolve the base directory that stores this session (grandparent of /// `session_dir`, i.e. the sessions root, not the individual session /// folder). /// /// Why: falls back progressively -- grandparent, then parent, then /// `session_dir` itself -- logging a warning at each step down, so this /// never fails even on a shallow path. pub fn store_base_dir(&self) -> std::path::PathBuf { self.session_dir .parent() .and_then(|p| p.parent()) .map_or_else( || { tracing::warn!( "[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display() ); self.session_dir.parent().map_or_else( || { tracing::warn!( "[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display() ); self.session_dir.clone() }, std::path::Path::to_path_buf, ) }, std::path::Path::to_path_buf, ) } /// Persist the current settings to the store and swallow any error. /// /// Inline usage of `JsonSettingsRepository::new().save(...)` was /// duplicated twice in `controller/input.rs` — this helper centralises /// the call site. pub fn save_settings(&self) { use zesdex_cms::domain::repository::SettingsRepository; let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() .save(&self.store_base_dir(), &self.settings); } /// Build a `ToolCtx` for tool calls originating from the main agent. pub fn tool_ctx(&self) -> crate::tool::ToolCtx { self.tool_ctx_for(Origin::Main) } /// Build a `ToolCtx` scoped to the given call origin (main, subagent, /// reviewer), copying workspace/session/memory paths from state. pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx { crate::tool::ToolCtx { workspaces: self.workspace_roots.clone(), session_dir: self.session_dir.clone(), memory_dir: self.memory_dir.clone(), worktrees_dir: self.worktrees_dir.clone(), dir_cache: self.dir_cache.clone(), mention_index: self.mention_index.clone(), origin, graduated_checks: Vec::new(), lsp_manager: self.lsp_manager.clone(), turn_events: Some(self.turn_events.clone()), workflow_findings: None, abort_flag: Some(self.abort_flag.clone()), } } } #[cfg(test)] mod tests { use super::*; #[test] fn tool_ctx_for_shares_the_session_abort_flag() { let tmp = std::env::temp_dir().join(format!("zesdex-rest-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&tmp).unwrap(); let state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory")); let ctx = state.tool_ctx_for(Origin::Main); assert!(ctx.abort_flag.is_some()); assert!(std::sync::Arc::ptr_eq( ctx.abort_flag.as_ref().unwrap(), &state.abort_flag, )); std::fs::remove_dir_all(&tmp).ok(); } }