//! 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::misc::{DirCache, InputState, MiscState, 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 crate::model::app_config::AppConfig; use crate::model::editlog::EditLog; use crate::model::settings::Settings; /// 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 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: PathBuf, memory_dir: PathBuf) -> Self { let settings = Settings::load(); let app_config = AppConfig::load(); 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(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| { tracing::warn!("[state] session_dir has no file_name component, using empty session_id"); String::new() }); let mut state = AppStateRest { settings, app_config, workspace_roots, session_id, session_dir: session_dir.clone(), 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)), edit_log: EditLog::new(&session_dir), session_runtime: Some(SessionRuntime::new(session_dir.clone())), 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 = format!("{:x}", hasher.finalize()); let folder_name = abs_root.file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| "root".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(|s| s.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.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: {} connected ✓", name); 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 } /// 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(|g| *g).unwrap_or_else(|_| { tracing::warn!("[state] turn_in_flight mutex poisoned"); false }) } /// 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; } /// 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.dirty = true; } /// 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(|p| p.to_path_buf()) .unwrap_or_else(|| { tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display()); self.session_dir.parent() .map(|p| p.to_path_buf()) .unwrap_or_else(|| { tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display()); self.session_dir.clone() }) }) } /// 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(), origin, graduated_checks: Vec::new(), lsp_manager: self.lsp_manager.clone(), turn_events: Some(self.turn_events.clone()), workflow_findings: None, } } }