refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
//! Shallow state diffing — records opaque "modified" markers so the TUI
|
||||
//! knows to re-render without computing fine-grained deltas.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A collection of changes tracking which parts of app state have been
|
||||
/// modified since the last render sweep.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateDiff {
|
||||
changes: Vec<Change>,
|
||||
}
|
||||
|
||||
/// A single named change — currently always carries a flat `"."` path
|
||||
/// and `"modified"` kind because the system does not track granular diffs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Change {
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
impl StateDiff {
|
||||
/// Create an empty diff.
|
||||
pub fn new() -> Self {
|
||||
StateDiff { changes: Vec::new() }
|
||||
}
|
||||
|
||||
/// Record a change at `path` of the given `kind`.
|
||||
pub fn add_change(&mut self, path: String, kind: String) {
|
||||
self.changes.push(Change { path, kind });
|
||||
}
|
||||
|
||||
/// Return true if no changes have been recorded.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty()
|
||||
}
|
||||
|
||||
/// Remove all recorded changes.
|
||||
pub fn clear(&mut self) {
|
||||
self.changes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a shallow diff between two serialised state values.
|
||||
///
|
||||
/// Flow: compare with `==`, return an empty vec if equal, otherwise
|
||||
/// return a single `Change { ".", "modified" }`.
|
||||
///
|
||||
/// Why: a placeholder — the current rendering model re-validates the
|
||||
/// whole viewport every frame, so fine-grained diffs are unnecessary.
|
||||
///
|
||||
/// Return: the list of changes (always 0 or 1 entry).
|
||||
pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> {
|
||||
if before == after {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Change {
|
||||
path: ".".to_string(),
|
||||
kind: "modified".to_string(),
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
//! Application-level "miscellaneous" state: scroll, input buffer,
|
||||
//! overlay stack, toasts, editor, and autocomplete.
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use super::types::Overlay;
|
||||
|
||||
/// A shared, async-writable cache of directory entries, used to avoid
|
||||
/// re-reading a directory every render frame.
|
||||
#[derive(Clone)]
|
||||
pub struct DirCache {
|
||||
entries: Arc<RwLock<Vec<PathBuf>>>,
|
||||
}
|
||||
|
||||
impl DirCache {
|
||||
/// Create an empty `DirCache`.
|
||||
pub fn new() -> Self {
|
||||
DirCache {
|
||||
entries: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the cached entries (async write).
|
||||
pub async fn set(&self, paths: Vec<PathBuf>) {
|
||||
let mut w = self.entries.write().await;
|
||||
*w = paths;
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared, whole-workspace file-path index used for `@file` mention
|
||||
/// autocomplete. Built once by a background thread at startup (see
|
||||
/// `AppStateRest::new`) and incrementally appended to when tools create
|
||||
/// new files (see `tool/fs/write.rs`).
|
||||
#[derive(Clone)]
|
||||
pub struct MentionIndex {
|
||||
entries: Arc<std::sync::RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl MentionIndex {
|
||||
/// Create an empty `MentionIndex`.
|
||||
pub fn new() -> Self {
|
||||
MentionIndex {
|
||||
entries: Arc::new(std::sync::RwLock::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the indexed paths (used by the startup background walk).
|
||||
pub fn set(&self, paths: Vec<String>) {
|
||||
if let Ok(mut w) = self.entries.write() {
|
||||
*w = paths;
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a single newly created file's path (used by the `write` tool).
|
||||
pub fn push(&self, path: String) {
|
||||
if let Ok(mut w) = self.entries.write() {
|
||||
w.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a snapshot of the current indexed paths for fuzzy matching.
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.entries.read().map(|r| r.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which source populated the autocomplete dropdown, since selecting a
|
||||
/// candidate is spliced into the buffer differently for each.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutocompleteKind {
|
||||
Command,
|
||||
FileMention,
|
||||
}
|
||||
|
||||
/// Manages the viewport scroll offset.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScrollState {
|
||||
pub offset: usize,
|
||||
pub max_visible: usize,
|
||||
}
|
||||
|
||||
impl ScrollState {
|
||||
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
||||
pub fn new() -> Self {
|
||||
ScrollState {
|
||||
offset: 0,
|
||||
max_visible: 30,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_add(amount);
|
||||
}
|
||||
|
||||
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.offset = self.offset.saturating_sub(amount);
|
||||
}
|
||||
|
||||
/// Update the maximum number of visible lines.
|
||||
pub fn set_max_visible(&mut self, max: usize) {
|
||||
self.max_visible = max;
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's input buffer, cursor position, history, and autocomplete
|
||||
/// state for the chat prompt.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InputState {
|
||||
pub buffer: String,
|
||||
pub cursor: usize,
|
||||
pub history: Vec<String>,
|
||||
pub history_idx: Option<usize>,
|
||||
pub autocomplete_prefix: String,
|
||||
pub autocomplete_candidates: Vec<String>,
|
||||
pub autocomplete_idx: usize,
|
||||
pub autocomplete_visible: bool,
|
||||
pub autocomplete_kind: AutocompleteKind,
|
||||
pub mention_start: usize,
|
||||
pub history_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help",
|
||||
"/quit",
|
||||
"/clear",
|
||||
"/login",
|
||||
"/login zen",
|
||||
"/login openai",
|
||||
"/edit",
|
||||
"/mcp add",
|
||||
"/model",
|
||||
"/model ls",
|
||||
"/model add",
|
||||
|
||||
"/todo",
|
||||
"/usage",
|
||||
"/compact",
|
||||
];
|
||||
|
||||
impl InputState {
|
||||
/// Create an empty input state with no buffer, no history, and no
|
||||
/// autocomplete.
|
||||
pub fn new() -> Self {
|
||||
InputState {
|
||||
buffer: String::new(),
|
||||
cursor: 0,
|
||||
history: Vec::new(),
|
||||
history_idx: None,
|
||||
autocomplete_prefix: String::new(),
|
||||
autocomplete_candidates: Vec::new(),
|
||||
autocomplete_idx: 0,
|
||||
autocomplete_visible: false,
|
||||
autocomplete_kind: AutocompleteKind::Command,
|
||||
mention_start: 0,
|
||||
history_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the autocomplete dropdown and clear its state.
|
||||
pub fn close_autocomplete(&mut self) {
|
||||
self.autocomplete_visible = false;
|
||||
self.autocomplete_candidates.clear();
|
||||
self.autocomplete_prefix.clear();
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.mention_start = 0;
|
||||
}
|
||||
|
||||
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`
|
||||
/// against the current buffer prefix.
|
||||
///
|
||||
/// Flow: if buffer is empty or doesn't start with `/`, close and return
|
||||
/// → filter `COMMANDS` by prefix match → store candidates → set
|
||||
/// `autocomplete_visible` if any candidates found.
|
||||
pub fn open_autocomplete(&mut self) {
|
||||
let trimmed = self.buffer.trim().to_string();
|
||||
if trimmed.is_empty() || !trimmed.starts_with('/') {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = trimmed.to_lowercase();
|
||||
self.autocomplete_candidates = COMMANDS
|
||||
.iter()
|
||||
.filter(|c| c.starts_with(&prefix))
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
self.autocomplete_prefix = prefix;
|
||||
self.autocomplete_kind = AutocompleteKind::Command;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Find the `@mention` token (if any) immediately before the cursor.
|
||||
///
|
||||
/// Flow: find the nearest `@` before the cursor → if there's whitespace
|
||||
/// between that `@` and the cursor, no trigger → the `@` only counts as
|
||||
/// a trigger if it's at buffer start or immediately preceded by
|
||||
/// whitespace (so `foo@bar` mid-word never triggers).
|
||||
///
|
||||
/// Return: `Some((byte offset of '@', query text between '@' and cursor))`
|
||||
/// or `None` if the cursor isn't inside a mention token.
|
||||
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
|
||||
let before_cursor = &self.buffer[..self.cursor];
|
||||
let at_pos = before_cursor.rfind('@')?;
|
||||
let between = &before_cursor[at_pos + 1..];
|
||||
if between.chars().any(char::is_whitespace) {
|
||||
return None;
|
||||
}
|
||||
let boundary_ok = at_pos == 0
|
||||
|| before_cursor[..at_pos].chars().next_back().is_some_and(char::is_whitespace);
|
||||
if !boundary_ok {
|
||||
return None;
|
||||
}
|
||||
Some((at_pos, between.to_string()))
|
||||
}
|
||||
|
||||
/// Open or refresh the `@file` mention dropdown from `files`, fuzzy-matched
|
||||
/// against the mention query at the cursor.
|
||||
///
|
||||
/// Flow: `mention_query_at_cursor` finds the trigger `@` and query text →
|
||||
/// if none, close and return → otherwise fuzzy-match `query` against
|
||||
/// `files` via `nucleo-matcher`, keep the top 10 by score.
|
||||
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
|
||||
use nucleo_matcher::{Config, Matcher};
|
||||
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
|
||||
let Some((start, query)) = self.mention_query_at_cursor() else {
|
||||
self.close_autocomplete();
|
||||
return;
|
||||
};
|
||||
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
|
||||
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
|
||||
let matched_files = pattern.match_list(files.iter(), &mut matcher);
|
||||
self.autocomplete_candidates = matched_files.into_iter().take(10).map(|(f, _)| f.clone()).collect();
|
||||
self.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
self.mention_start = start;
|
||||
self.autocomplete_idx = 0;
|
||||
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
|
||||
}
|
||||
|
||||
/// Move the autocomplete selection up (forward=false) or down (forward=true).
|
||||
/// Wraps around at the boundaries.
|
||||
pub fn cycle_autocomplete(&mut self, forward: bool) {
|
||||
let n = self.autocomplete_candidates.len();
|
||||
if n == 0 { return; }
|
||||
if forward {
|
||||
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
|
||||
} else {
|
||||
self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 };
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept the currently selected autocomplete candidate.
|
||||
///
|
||||
/// `Command` candidates replace the whole buffer; `FileMention`
|
||||
/// candidates splice `@path ` in at the mention's start position so the
|
||||
/// rest of the sentence around it is preserved.
|
||||
///
|
||||
/// Return: `true` if a candidate was selected, `false` if none existed.
|
||||
pub fn select_autocomplete(&mut self) -> bool {
|
||||
let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else {
|
||||
return false;
|
||||
};
|
||||
match self.autocomplete_kind {
|
||||
AutocompleteKind::Command => {
|
||||
self.buffer = candidate;
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
AutocompleteKind::FileMention => {
|
||||
// Cursor movement (Left/Right) does not close the dropdown, so
|
||||
// by the time Enter is pressed `mention_start` may no longer
|
||||
// describe a valid range against the current cursor/buffer
|
||||
// (e.g. the cursor moved left past the '@'). Splicing on a
|
||||
// stale range would panic (`start > end`) or, even when it
|
||||
// doesn't panic, produce a nonsensical replacement. Treat a
|
||||
// stale mention context the same as "nothing selected".
|
||||
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
|
||||
self.close_autocomplete();
|
||||
return false;
|
||||
}
|
||||
let replacement = format!("@{candidate} ");
|
||||
self.buffer.replace_range(self.mention_start..self.cursor, &replacement);
|
||||
self.cursor = self.mention_start + replacement.len();
|
||||
}
|
||||
}
|
||||
self.close_autocomplete();
|
||||
true
|
||||
}
|
||||
|
||||
/// Legacy inline tab-complete — opens the dropdown on first Tab press,
|
||||
/// then cycles forward on subsequent presses.
|
||||
pub fn tab_complete(&mut self) {
|
||||
// Legacy inline tab-complete — used as a fallback when the dropdown
|
||||
// isn't visible yet. Opens the dropdown on the first Tab press.
|
||||
if self.autocomplete_visible {
|
||||
self.cycle_autocomplete(true);
|
||||
} else {
|
||||
self.open_autocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor left by one character (if not at the start).
|
||||
pub fn char_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the cursor right by one character (if not at the end).
|
||||
pub fn char_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a character at the cursor position.
|
||||
pub fn insert(&mut self, c: char) {
|
||||
self.buffer.insert(self.cursor, c);
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
/// Delete the character to the left of the cursor (backspace).
|
||||
pub fn delete_left(&mut self) {
|
||||
if self.cursor > 0 {
|
||||
self.cursor -= 1;
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the character at the cursor position (forward delete).
|
||||
pub fn delete_right(&mut self) {
|
||||
if self.cursor < self.buffer.len() {
|
||||
self.buffer.remove(self.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn submit(&mut self) -> String {
|
||||
let result = self.buffer.clone();
|
||||
if !result.is_empty() {
|
||||
if self.history.last() != Some(&result) {
|
||||
self.history.push(result.clone());
|
||||
if let Some(ref path) = self.history_file {
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(file, "{result}");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.history_idx = None;
|
||||
}
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
result
|
||||
}
|
||||
|
||||
/// Navigate backward through input history.
|
||||
pub fn history_up(&mut self) {
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let idx = match self.history_idx {
|
||||
Some(i) if i > 0 => i - 1,
|
||||
None => self.history.len() - 1,
|
||||
Some(_) => return,
|
||||
};
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
|
||||
/// Navigate forward through input history (back toward the newest entry).
|
||||
pub fn history_down(&mut self) {
|
||||
match self.history_idx {
|
||||
Some(i) if i < self.history.len() - 1 => {
|
||||
let idx = i + 1;
|
||||
self.history_idx = Some(idx);
|
||||
self.buffer = self.history[idx].clone();
|
||||
self.cursor = self.buffer.len();
|
||||
}
|
||||
Some(_) => {
|
||||
self.history_idx = None;
|
||||
self.buffer.clear();
|
||||
self.cursor = 0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "miscellaneous" slice of app state: which overlay is showing,
|
||||
/// toasts, thinking/connected flags, effort level, editor state, and tick.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MiscState {
|
||||
pub overlay: Overlay,
|
||||
pub toasts: Vec<super::types::Toast>,
|
||||
pub last_staleness_sweep_ms: i64,
|
||||
pub thinking: bool,
|
||||
pub effort_level: usize,
|
||||
pub selected_index: usize,
|
||||
pub editor: Option<super::super::mode::editor::EditorState>,
|
||||
pub api_connected: bool,
|
||||
#[allow(dead_code)]
|
||||
pub api_context_length: Option<u32>,
|
||||
pub tick_count: u64,
|
||||
pub todo_content: String,
|
||||
pub lesson_running: bool,
|
||||
pub pending_clipboard_copy: Option<String>,
|
||||
}
|
||||
|
||||
impl MiscState {
|
||||
/// Create a fresh `MiscState` with no overlay, no toasts, and default
|
||||
/// effort level 1.
|
||||
pub fn new() -> Self {
|
||||
MiscState {
|
||||
overlay: Overlay::None,
|
||||
toasts: Vec::new(),
|
||||
last_staleness_sweep_ms: 0,
|
||||
thinking: false,
|
||||
effort_level: 1,
|
||||
selected_index: 0,
|
||||
editor: None,
|
||||
api_connected: false,
|
||||
api_context_length: None,
|
||||
tick_count: 0,
|
||||
todo_content: String::new(),
|
||||
lesson_running: false,
|
||||
pending_clipboard_copy: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_toast(&mut self, toast: super::types::Toast) {
|
||||
self.toasts.push(toast);
|
||||
}
|
||||
|
||||
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
|
||||
///
|
||||
/// Return: the expired toasts (after removal).
|
||||
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
|
||||
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
|
||||
self.toasts.retain(|t| !t.expired(now_ms));
|
||||
expired
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn input_with(buffer: &str, cursor: usize) -> InputState {
|
||||
let mut input = InputState::new();
|
||||
input.buffer = buffer.to_string();
|
||||
input.cursor = cursor;
|
||||
input
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_at_buffer_start_triggers() {
|
||||
let input = input_with("@mai", 4);
|
||||
assert_eq!(input.mention_query_at_cursor(), Some((0, "mai".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_after_space_mid_sentence_triggers() {
|
||||
let input = input_with("look at @read", 13);
|
||||
assert_eq!(input.mention_query_at_cursor(), Some((8, "read".to_string())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mid_word_at_does_not_trigger() {
|
||||
let input = input_with("foo@bar", 7);
|
||||
assert_eq!(input.mention_query_at_cursor(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_between_at_and_cursor_does_not_trigger() {
|
||||
let input = input_with("@foo bar", 8);
|
||||
assert_eq!(input.mention_query_at_cursor(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_file_mention_splices_into_buffer() {
|
||||
let mut input = input_with("look at @rea and fix it", 12);
|
||||
input.autocomplete_candidates = vec!["src/main.rs".to_string()];
|
||||
input.autocomplete_idx = 0;
|
||||
input.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
input.mention_start = 8;
|
||||
assert!(input.select_autocomplete());
|
||||
assert_eq!(input.buffer, "look at @src/main.rs and fix it");
|
||||
assert_eq!(input.cursor, 8 + "@src/main.rs ".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_file_mention_with_stale_cursor_before_mention_start_does_not_panic() {
|
||||
// Simulates: user typed "foo @rea" (mention_start = 4, cursor = 8,
|
||||
// dropdown open), then pressed Left 5 times without closing the
|
||||
// dropdown, moving the cursor to byte 3 (before the '@'). Selecting
|
||||
// now must not panic on `replace_range(4..3, ...)`.
|
||||
let mut input = input_with("foo @rea", 3);
|
||||
input.autocomplete_candidates = vec!["src/main.rs".to_string()];
|
||||
input.autocomplete_idx = 0;
|
||||
input.autocomplete_kind = AutocompleteKind::FileMention;
|
||||
input.mention_start = 4;
|
||||
assert!(!input.select_autocomplete());
|
||||
assert!(!input.autocomplete_visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_command_still_replaces_whole_buffer() {
|
||||
let mut input = input_with("/mo", 3);
|
||||
input.autocomplete_candidates = vec!["/model".to_string()];
|
||||
input.autocomplete_idx = 0;
|
||||
input.autocomplete_kind = AutocompleteKind::Command;
|
||||
assert!(input.select_autocomplete());
|
||||
assert_eq!(input.buffer, "/model");
|
||||
assert_eq!(input.cursor, "/model".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misc_state_starts_with_no_pending_clipboard_copy() {
|
||||
let misc = MiscState::new();
|
||||
assert!(misc.pending_clipboard_copy.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Application state: misc fields, the main `AppStateRest` struct,
|
||||
//! runtime-only state, and shared types (overlays, toasts, origins).
|
||||
pub mod misc;
|
||||
pub mod rest;
|
||||
pub mod runtime;
|
||||
pub mod types;
|
||||
@@ -0,0 +1,361 @@
|
||||
//! 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, MentionIndex, 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<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<crate::model::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 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_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: EditLog::new(session_dir),
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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_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)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Per-session runtime state: message history, pending tool queue,
|
||||
//! background bash jobs, lesson/review counters, and the `TurnEvent`
|
||||
//! stream emitted while an agent turn is in flight.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
pub tokens_in: u64,
|
||||
pub tokens_out: u64,
|
||||
#[serde(default)]
|
||||
pub last_tokens_in: u64,
|
||||
#[serde(default)]
|
||||
pub last_tokens_out: u64,
|
||||
pub api_calls: u64,
|
||||
pub review_tokens: u64,
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
/// Mutable, serializable state for one session: chat history, tool
|
||||
/// results, pending tools, background jobs, and lesson/review counters
|
||||
/// shown in the TUI status bar.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionRuntime {
|
||||
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
|
||||
pub tool_call_results: Vec<ToolCallResult>,
|
||||
pub pending_tool_queue: Vec<PendingTool>,
|
||||
pub bash_jobs: Vec<BashJobRef>,
|
||||
pub subagent_queue: usize,
|
||||
pub edit_count: u32,
|
||||
pub consecutive_empty_reviews: u32,
|
||||
pub session_start: i64,
|
||||
pub lesson_count: u32,
|
||||
pub lessons_user: u32,
|
||||
pub lessons_feedback: u32,
|
||||
pub lessons_project: u32,
|
||||
pub lessons_reference: u32,
|
||||
pub lessons_active: u32,
|
||||
pub lessons_stale: u32,
|
||||
pub lessons_contradicted: u32,
|
||||
pub lessons_human: u32,
|
||||
pub lessons_verified: u32,
|
||||
pub lessons_unverified: u32,
|
||||
pub review_count: u32,
|
||||
pub session_dir: PathBuf,
|
||||
pub usage: UsageStats,
|
||||
/// Whether a hive-mind convergence has completed at least once in this
|
||||
/// session. Set by the main-thread event loop when it receives a
|
||||
/// `TurnEvent::SystemNote { kind: "hive_mind_converged", .. }` — the
|
||||
/// only reliable way to detect this across turns, since system messages
|
||||
/// pushed mid-turn inside `run_agent_turn` are NOT persisted into
|
||||
/// `rt.messages` (they stay local to that turn's background thread and
|
||||
/// are only archived to `SQLite`).
|
||||
pub hive_mind_converged: bool,
|
||||
}
|
||||
|
||||
/// Record of one completed tool invocation, kept for transcript/history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallResult {
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
pub output: String,
|
||||
pub is_error: bool,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// A tool call awaiting execution, along with which execution model
|
||||
/// (inline, deferred, async) it should run under.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PendingTool {
|
||||
pub tool_name: String,
|
||||
pub args: serde_json::Value,
|
||||
pub execution_model: crate::app::state::types::ExecutionModel,
|
||||
}
|
||||
|
||||
/// Reference to a background bash job tracked in session state (the actual
|
||||
/// process handle lives elsewhere; this is just the display/status record).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BashJobRef {
|
||||
pub id: String,
|
||||
pub command: String,
|
||||
pub started_at: i64,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Events emitted onto the turn-event queue while an agent turn runs,
|
||||
/// consumed by the event loop to update state and drive re-renders.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEvent {
|
||||
AssistantMessage(crate::dto::chat::message::ChatMessage),
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
path: Option<String>,
|
||||
},
|
||||
SystemNote {
|
||||
kind: String,
|
||||
message: String,
|
||||
},
|
||||
StreamStart,
|
||||
StreamToken(String),
|
||||
StreamDone(crate::dto::chat::message::ChatMessage),
|
||||
Usage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
},
|
||||
/// Token usage from a subagent (review, test-gen, arch-review, etc.)
|
||||
/// routed to `UsageStats::review_tokens` so the Usage panel can split
|
||||
/// "main" tokens from "self-learning" tokens. Same shape as `Usage` but
|
||||
/// kept as a distinct variant so future subagent-specific metadata
|
||||
/// (origin tag, subagent name) can be attached without breaking the
|
||||
/// main-agent path.
|
||||
ReviewUsage {
|
||||
tokens_in: u64,
|
||||
tokens_out: u64,
|
||||
},
|
||||
Compacted(Vec<crate::dto::chat::message::ChatMessage>),
|
||||
Error(String),
|
||||
Done,
|
||||
/// Real-time update from a workflow subagent: push the new status
|
||||
/// into `AppStateRest::workflow_engine.agents`.
|
||||
WorkflowAgentUpdate {
|
||||
agent_id: String,
|
||||
agent_name: String,
|
||||
status: crate::app::workflow::engine::AgentStatus,
|
||||
},
|
||||
}
|
||||
|
||||
impl SessionRuntime {
|
||||
/// Create fresh runtime state for a session rooted at `session_dir`,
|
||||
/// with all counters zeroed and `session_start` set to now.
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
SessionRuntime {
|
||||
messages: Vec::new(),
|
||||
tool_call_results: Vec::new(),
|
||||
pending_tool_queue: Vec::new(),
|
||||
bash_jobs: Vec::new(),
|
||||
subagent_queue: 0,
|
||||
edit_count: 0,
|
||||
consecutive_empty_reviews: 0,
|
||||
session_start: chrono::Utc::now().timestamp_millis(),
|
||||
lesson_count: 0,
|
||||
lessons_user: 0,
|
||||
lessons_feedback: 0,
|
||||
lessons_project: 0,
|
||||
lessons_reference: 0,
|
||||
lessons_active: 0,
|
||||
lessons_stale: 0,
|
||||
lessons_contradicted: 0,
|
||||
lessons_human: 0,
|
||||
lessons_verified: 0,
|
||||
lessons_unverified: 0,
|
||||
review_count: 0,
|
||||
session_dir,
|
||||
usage: UsageStats::default(),
|
||||
hive_mind_converged: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message to the session's conversation history.
|
||||
pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Opaque, serializable snapshot of application state used for
|
||||
//! attach/daemon IPC transfer.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A JSON-boxed snapshot of app state, opaque to the transport layer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StateSnapshot {
|
||||
pub snapshot: serde_json::Value,
|
||||
}
|
||||
|
||||
impl StateSnapshot {
|
||||
/// Create an empty snapshot (`{}`).
|
||||
pub fn new() -> Self {
|
||||
StateSnapshot {
|
||||
snapshot: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize a snapshot to bytes for transport over the daemon socket.
|
||||
///
|
||||
/// Return: JSON-encoded bytes, or a serde error.
|
||||
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(snapshot)?)
|
||||
}
|
||||
|
||||
/// Parse a snapshot previously produced by `serialize_snapshot`.
|
||||
///
|
||||
/// Return: the decoded `StateSnapshot`, or a serde error.
|
||||
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
|
||||
Ok(serde_json::from_slice(data)?)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Shared small state types: toasts, overlays, the transcript cache,
|
||||
//! tool execution model, and call origin tags.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Severity/category of a toast notification, used to pick its color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ToastKind {
|
||||
Info,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Lesson,
|
||||
}
|
||||
|
||||
/// A transient status message shown in the TUI, auto-dismissed after
|
||||
/// `lifetime_ms`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Toast {
|
||||
pub kind: ToastKind,
|
||||
pub message: String,
|
||||
pub created_at: i64,
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
impl Toast {
|
||||
/// Create a toast with a default 5-second lifetime, stamped with now.
|
||||
pub fn new(kind: ToastKind, message: String) -> Self {
|
||||
Toast {
|
||||
kind,
|
||||
message,
|
||||
created_at: chrono::Utc::now().timestamp_millis(),
|
||||
lifetime_ms: 5000,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this toast's lifetime has elapsed as of `now_ms`.
|
||||
pub fn expired(&self, now_ms: i64) -> bool {
|
||||
now_ms - self.created_at > self.lifetime_ms as i64
|
||||
}
|
||||
}
|
||||
|
||||
/// Which modal overlay, if any, is currently shown over the main TUI view.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Overlay {
|
||||
None,
|
||||
Help,
|
||||
Settings,
|
||||
Bash,
|
||||
QuitConfirm,
|
||||
|
||||
KeyInput,
|
||||
Editor,
|
||||
Effort,
|
||||
Mcp,
|
||||
Todo,
|
||||
Rewind,
|
||||
Learning,
|
||||
Usage,
|
||||
Loading,
|
||||
ModelSelector,
|
||||
ClearConfirm,
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
/// Whether any overlay (i.e. anything other than `None`) is active.
|
||||
pub fn is_active(self) -> bool {
|
||||
!matches!(self, Overlay::None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded ring of recent chat messages used to render the transcript view.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TranscriptCache {
|
||||
pub messages: Vec<super::rest::ChatMessageDisplay>,
|
||||
pub max_lines: usize,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
/// Create an empty transcript cache holding at most `max_lines` messages.
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
TranscriptCache {
|
||||
messages: Vec::new(),
|
||||
max_lines,
|
||||
dirty: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a pending tool call should be executed when the turn resumes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ExecutionModel {
|
||||
Inline,
|
||||
Deferred,
|
||||
AsyncTokio,
|
||||
}
|
||||
|
||||
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||
pub enum Origin {
|
||||
Main,
|
||||
SubAgent,
|
||||
Reviewer,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
/// Short string tag for this origin, used in filenames and logs.
|
||||
pub fn tag(self) -> String {
|
||||
match self {
|
||||
Origin::Main => "main".to_string(),
|
||||
Origin::SubAgent => "subagent".to_string(),
|
||||
Origin::Reviewer => "reviewer".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user