2026-07-12 11:28:39 +07:00
|
|
|
//! 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.
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
use std::collections::VecDeque;
|
2026-07-11 13:16:10 +07:00
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use tokio::sync::RwLock;
|
|
|
|
|
|
|
|
|
|
use super::misc::{DirCache, InputState, MiscState, ScrollState};
|
2026-07-11 20:21:59 +07:00
|
|
|
use super::runtime::{SessionRuntime, TurnEvent};
|
2026-07-12 03:14:52 +07:00
|
|
|
use super::types::{Origin, Toast, TranscriptCache};
|
2026-07-12 13:40:58 +07:00
|
|
|
use crate::app::lsp::LspManager;
|
2026-07-11 20:21:59 +07:00
|
|
|
use crate::app::mcp::manager::McpManager;
|
|
|
|
|
use crate::app::workflow::engine::WorkflowEngine;
|
2026-07-12 01:25:52 +07:00
|
|
|
use crate::model::app_config::AppConfig;
|
2026-07-11 13:16:10 +07:00
|
|
|
use crate::model::editlog::EditLog;
|
|
|
|
|
use crate::model::settings::Settings;
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// A single transcript entry rendered in the TUI chat pane.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
|
|
|
pub struct ChatMessageDisplay {
|
|
|
|
|
pub role: crate::dto::chat::message::Role,
|
|
|
|
|
pub content: String,
|
|
|
|
|
pub timestamp: i64,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ChatMessageDisplay {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Build a display entry, stamping it with the current time.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
|
|
|
|
|
ChatMessageDisplay {
|
|
|
|
|
role,
|
|
|
|
|
content,
|
|
|
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct AppStateRest {
|
2026-07-12 03:14:52 +07:00
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
pub settings: Settings,
|
2026-07-12 01:25:52 +07:00
|
|
|
pub app_config: AppConfig,
|
2026-07-11 13:16:10 +07:00
|
|
|
pub workspace_roots: Vec<PathBuf>,
|
2026-07-11 20:21:59 +07:00
|
|
|
pub session_id: String,
|
2026-07-11 13:16:10 +07:00
|
|
|
pub session_dir: PathBuf,
|
|
|
|
|
pub memory_dir: PathBuf,
|
|
|
|
|
pub download_dir: PathBuf,
|
|
|
|
|
pub worktrees_dir: PathBuf,
|
|
|
|
|
pub dir_cache: Arc<RwLock<DirCache>>,
|
|
|
|
|
pub edit_log: EditLog,
|
|
|
|
|
pub session_runtime: Option<SessionRuntime>,
|
|
|
|
|
pub sessions: Vec<crate::model::session::Session>,
|
|
|
|
|
pub transcript_cache: TranscriptCache,
|
|
|
|
|
pub scroll: ScrollState,
|
|
|
|
|
pub input: InputState,
|
|
|
|
|
pub misc: MiscState,
|
2026-07-11 20:21:59 +07:00
|
|
|
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
|
|
|
|
pub turn_in_flight: Arc<Mutex<bool>>,
|
2026-07-12 03:14:52 +07:00
|
|
|
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
|
2026-07-11 20:21:59 +07:00
|
|
|
pub workflow_engine: WorkflowEngine,
|
|
|
|
|
pub mcp_manager: McpManager,
|
2026-07-12 13:40:58 +07:00
|
|
|
pub lsp_manager: Arc<Mutex<LspManager>>,
|
2026-07-12 15:19:13 +07:00
|
|
|
/// Shared queue: provisioner thread pushes status updates,
|
|
|
|
|
/// drained into toasts on each Tick.
|
|
|
|
|
pub lsp_provision_msgs: Arc<Mutex<VecDeque<String>>>,
|
2026-07-11 13:16:10 +07:00
|
|
|
pub dirty: bool,
|
|
|
|
|
pub quit: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl AppStateRest {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
|
|
|
|
|
let settings = Settings::load();
|
2026-07-12 01:25:52 +07:00
|
|
|
let app_config = AppConfig::load();
|
2026-07-12 10:50:34 +07:00
|
|
|
let download_dir = memory_dir.parent().unwrap_or_else(|| {
|
2026-07-12 10:57:32 +07:00
|
|
|
tracing::warn!("[state] memory_dir '{}' has no parent, using it for downloads", memory_dir.display());
|
2026-07-12 10:50:34 +07:00
|
|
|
&memory_dir
|
|
|
|
|
}).join("downloads");
|
|
|
|
|
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
|
2026-07-12 10:57:32 +07:00
|
|
|
tracing::warn!("[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display());
|
2026-07-12 10:50:34 +07:00
|
|
|
&memory_dir
|
|
|
|
|
}).join("worktrees");
|
2026-07-11 13:16:10 +07:00
|
|
|
let dir_cache = DirCache::new();
|
2026-07-11 20:21:59 +07:00
|
|
|
let session_id = session_dir
|
|
|
|
|
.file_name()
|
|
|
|
|
.map(|n| n.to_string_lossy().to_string())
|
2026-07-12 10:50:34 +07:00
|
|
|
.unwrap_or_else(|| {
|
2026-07-12 10:57:32 +07:00
|
|
|
tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
|
2026-07-12 10:50:34 +07:00
|
|
|
String::new()
|
|
|
|
|
});
|
2026-07-12 14:47:01 +07:00
|
|
|
let state = AppStateRest {
|
2026-07-12 03:14:52 +07:00
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
settings,
|
2026-07-12 01:25:52 +07:00
|
|
|
app_config,
|
2026-07-11 13:16:10 +07:00
|
|
|
workspace_roots,
|
2026-07-11 20:21:59 +07:00
|
|
|
session_id,
|
2026-07-11 13:16:10 +07:00
|
|
|
session_dir: session_dir.clone(),
|
|
|
|
|
memory_dir,
|
|
|
|
|
download_dir,
|
|
|
|
|
worktrees_dir,
|
2026-07-11 20:21:59 +07:00
|
|
|
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
|
|
|
|
turn_in_flight: Arc::new(Mutex::new(false)),
|
2026-07-12 03:14:52 +07:00
|
|
|
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
2026-07-11 13:16:10 +07:00
|
|
|
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
|
|
|
|
edit_log: EditLog::new(&session_dir),
|
|
|
|
|
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
|
2026-07-11 20:21:59 +07:00
|
|
|
workflow_engine: WorkflowEngine::new(),
|
|
|
|
|
mcp_manager: McpManager::new(),
|
2026-07-12 15:19:13 +07:00
|
|
|
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
|
2026-07-12 13:40:58 +07:00
|
|
|
lsp_manager: Arc::new(Mutex::new(LspManager::new())),
|
2026-07-11 13:16:10 +07:00
|
|
|
sessions: Vec::new(),
|
|
|
|
|
transcript_cache: TranscriptCache::new(200),
|
|
|
|
|
scroll: ScrollState::new(),
|
|
|
|
|
input: InputState::new(),
|
|
|
|
|
misc: MiscState::new(),
|
|
|
|
|
dirty: true,
|
|
|
|
|
quit: false,
|
2026-07-12 14:47:01 +07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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();
|
2026-07-12 15:19:13 +07:00
|
|
|
let msg_queue = state.lsp_provision_msgs.clone();
|
2026-07-12 14:47:01 +07:00
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
use crate::app::lsp::provisioner::{self, ProvisionResult};
|
|
|
|
|
|
2026-07-12 15:19:13 +07:00
|
|
|
fn push_msg(q: &Arc<Mutex<VecDeque<String>>>, msg: String) {
|
|
|
|
|
if let Ok(mut q) = q.lock() {
|
|
|
|
|
q.push_back(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
push_msg(&msg_queue, "LSP: provisioning servers...".to_string());
|
2026-07-12 14:47:01 +07:00
|
|
|
let results = provisioner::provision_all();
|
2026-07-12 15:19:13 +07:00
|
|
|
push_msg(&msg_queue, "LSP: connecting servers...".to_string());
|
2026-07-12 14:47:01 +07:00
|
|
|
let connected = provisioner::auto_connect(&lsp_mgr, &results);
|
|
|
|
|
for name in &connected {
|
|
|
|
|
tracing::info!("LSP: {} connected", name);
|
2026-07-12 15:19:13 +07:00
|
|
|
push_msg(&msg_queue, format!("LSP: {} connected ✓", name));
|
2026-07-12 14:47:01 +07:00
|
|
|
}
|
|
|
|
|
for r in &results {
|
|
|
|
|
if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
|
|
|
|
|
tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
|
2026-07-12 15:19:13 +07:00
|
|
|
push_msg(&msg_queue, format!("LSP: {} ({}) ✗ - {}", server_name, language, reason));
|
2026-07-12 14:47:01 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-12 15:19:13 +07:00
|
|
|
if connected.is_empty() {
|
|
|
|
|
push_msg(&msg_queue, "LSP: no servers available — install manually or check prerequisites".to_string());
|
|
|
|
|
} else {
|
|
|
|
|
push_msg(&msg_queue, format!("LSP: {} server(s) connected", connected.len()));
|
|
|
|
|
}
|
2026-07-12 14:47:01 +07:00
|
|
|
});
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-12 14:47:01 +07:00
|
|
|
|
|
|
|
|
state
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Whether an agent turn is currently running.
|
|
|
|
|
///
|
|
|
|
|
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
|
|
|
|
|
/// than propagating a panic.
|
2026-07-11 22:10:17 +07:00
|
|
|
pub fn turn_in_flight(&self) -> bool {
|
2026-07-12 10:50:34 +07:00
|
|
|
self.turn_in_flight.lock().map(|g| *g).unwrap_or_else(|_| {
|
2026-07-12 10:57:32 +07:00
|
|
|
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
2026-07-12 10:50:34 +07:00
|
|
|
false
|
|
|
|
|
})
|
2026-07-11 22:10:17 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 14:47:01 +07:00
|
|
|
/// 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();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Append a message to the transcript, evicting the oldest entry once
|
|
|
|
|
/// `max_lines` is exceeded, and mark both the cache and the app dirty.
|
2026-07-11 13:16:10 +07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Queue a toast notification for display and mark the app dirty.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn push_toast(&mut self, toast: Toast) {
|
|
|
|
|
self.misc.push_toast(toast);
|
|
|
|
|
self.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 20:21:59 +07:00
|
|
|
pub fn store_base_dir(&self) -> std::path::PathBuf {
|
|
|
|
|
self.session_dir.parent()
|
|
|
|
|
.and_then(|p| p.parent())
|
|
|
|
|
.map(|p| p.to_path_buf())
|
2026-07-12 10:50:34 +07:00
|
|
|
.unwrap_or_else(|| {
|
2026-07-12 10:57:32 +07:00
|
|
|
tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
|
2026-07-12 10:50:34 +07:00
|
|
|
self.session_dir.parent()
|
|
|
|
|
.map(|p| p.to_path_buf())
|
|
|
|
|
.unwrap_or_else(|| {
|
2026-07-12 10:57:32 +07:00
|
|
|
tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
|
2026-07-12 10:50:34 +07:00
|
|
|
self.session_dir.clone()
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-07-11 20:21:59 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Build a `ToolCtx` for tool calls originating from the main agent.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
|
2026-07-11 20:21:59 +07:00
|
|
|
self.tool_ctx_for(Origin::Main)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Build a `ToolCtx` scoped to the given call origin (main, subagent,
|
|
|
|
|
/// reviewer), copying workspace/session/memory paths from state.
|
2026-07-11 20:21:59 +07:00
|
|
|
pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx {
|
2026-07-11 13:16:10 +07:00
|
|
|
crate::tool::ToolCtx {
|
|
|
|
|
workspaces: self.workspace_roots.clone(),
|
|
|
|
|
session_dir: self.session_dir.clone(),
|
|
|
|
|
memory_dir: self.memory_dir.clone(),
|
2026-07-11 23:45:13 +07:00
|
|
|
_download_dir: self.download_dir.clone(),
|
2026-07-11 13:16:10 +07:00
|
|
|
worktrees_dir: self.worktrees_dir.clone(),
|
|
|
|
|
dir_cache: self.dir_cache.clone(),
|
2026-07-11 20:21:59 +07:00
|
|
|
origin,
|
2026-07-11 18:23:01 +07:00
|
|
|
graduated_checks: Vec::new(),
|
2026-07-12 13:40:58 +07:00
|
|
|
lsp_manager: self.lsp_manager.clone(),
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|