Eliminate ~500 lines of duplicate code across 31 files by extracting shared functions, helpers, and consolidating repeated patterns. Highlights: - Toast helpers (toast_info/success/warning/error) on AppStateRest - push_event() helper for turn-event queue (19 callers consolidated) - log_write_edit_tool() shared fn (turn.rs + engine.rs ~50 lines saved) - resolve_api_key() shared fn (spawn.rs + provider.rs) - LSP call_positional() helper on LspClient - lsp_cursor_params() shared schema for 4 tool files -overlay_block() helper for consistent overlay title/border styling - cycle_selected_index(), path_not_found/a_directory() helpers - mark_dirty(), save_settings() on AppStateRest - Remove redundant Err(e) => Err(e) arms in LSP tools - Consolidate generate_workspace_tree (turn.rs → workspace.rs) - Simplify background-review wrapper args in auto/mod.rs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
470 lines
19 KiB
Rust
470 lines
19 KiB
Rust
//! 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<PathBuf>,
|
|
pub session_id: String,
|
|
pub session_dir: PathBuf,
|
|
pub memory_dir: PathBuf,
|
|
pub worktrees_dir: PathBuf,
|
|
pub dir_cache: Arc<RwLock<DirCache>>,
|
|
pub mention_index: MentionIndex,
|
|
pub edit_log: EditLog,
|
|
pub session_runtime: Option<SessionRuntime>,
|
|
pub sessions: Vec<zesdex_iam::domain::session::Session>,
|
|
pub transcript_cache: TranscriptCache,
|
|
pub scroll: ScrollState,
|
|
pub input: InputState,
|
|
pub misc: MiscState,
|
|
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
|
pub turn_in_flight: Arc<Mutex<bool>>,
|
|
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
|
|
pub workflow_engine: WorkflowEngine,
|
|
pub mcp_manager: McpManager,
|
|
pub lsp_manager: Arc<Mutex<LspManager>>,
|
|
/// Shared queue: provisioner thread pushes status updates,
|
|
/// drained into toasts on each Tick.
|
|
pub lsp_provision_msgs: Arc<Mutex<VecDeque<String>>>,
|
|
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<PathBuf>,
|
|
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<String> = 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<Mutex<VecDeque<String>>>, 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<String>) {
|
|
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<String>) {
|
|
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<String>) {
|
|
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<String>) {
|
|
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();
|
|
}
|
|
}
|