Files
zesdex/apps/interfaces/tui/src/state.rs
T

1033 lines
34 KiB
Rust

//! TUI-perspective application state: `AppStateRest` and all the types it
//! owns. This is the single source-of-truth struct for the TUI interface,
//! mutated from `controller/input.rs` and read by `view/` every render frame.
//!
//! Infrastructure types (SessionRuntime, DirCache, Toast, etc.) are imported
//! from `zesdex_infrastructure`; domain types (Settings, AppConfig, Role)
//! come from `zesdex_domain`.
//!
//! # Flow
//! Construction in `lib.rs::create_tui_state` → mutated by key events in
//! `controller/input.rs::handle_key` → read-only in every `view/*::draw*`
//! function.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use tracing::warn;
use zesdex_domain::cms::{AppConfig, Settings};
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
// ---------------------------------------------------------------------------
// Transcript display type
// ---------------------------------------------------------------------------
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
/// Message author: User or Assistant.
pub role: zesdex_domain::core::Role,
/// Rendered text content (plain text, no markdown).
pub content: String,
/// Millisecond timestamp when this display entry was created.
pub timestamp: i64,
}
impl ChatMessageDisplay {
/// Build a display entry, stamping it with the current time.
pub fn new(role: zesdex_domain::core::Role, content: String) -> Self {
ChatMessageDisplay {
role,
content,
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
}
// ---------------------------------------------------------------------------
// Bounded ring-buffer transcript cache
// ---------------------------------------------------------------------------
/// Bounded ring of recent chat messages used to render the transcript view.
#[derive(Debug, Clone)]
pub struct TranscriptCache {
/// Ordered display messages (newest appended, oldest evicted when full).
pub messages: Vec<ChatMessageDisplay>,
/// Maximum messages to retain before evicting the oldest.
pub max_lines: usize,
/// Whether the cache has changed since the last render sweep.
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,
}
}
}
// ---------------------------------------------------------------------------
// Scroll state
// ---------------------------------------------------------------------------
/// Viewport scroll state: current offset and visible-line count.
#[derive(Debug, Clone)]
pub struct ScrollState {
/// Current scroll offset (how many lines have been scrolled past).
pub offset: usize,
/// Maximum number of lines that fit in the visible viewport area.
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).
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);
}
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Input state (buffer, cursor, history, autocomplete)
// ---------------------------------------------------------------------------
/// Which source populated the autocomplete dropdown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteKind {
/// Builtin slash-command (e.g. `/model`, `/help`).
Command,
/// `@file` mention from the workspace file index.
FileMention,
}
/// Builtin slash-commands recognised by the chat input autocomplete.
const COMMANDS: &[&str] = &[
"/help",
"/quit",
"/clear",
"/login",
"/login zen",
"/login openai",
"/edit",
"/mcp add",
"/model",
"/model ls",
"/model add",
"/todo",
"/usage",
"/compact",
];
/// The user's input buffer, cursor position, history, and autocomplete
/// state for the chat prompt.
#[derive(Debug, Clone)]
pub struct InputState {
/// Raw UTF-8 input buffer content.
pub buffer: String,
/// Byte offset of the cursor within `buffer`.
pub cursor: usize,
/// Previously submitted input lines, oldest-first.
pub history: Vec<String>,
/// Index into `history` when browsing (None = at the current input).
pub history_idx: Option<usize>,
/// Current autocomplete candidate list.
pub autocomplete_candidates: Vec<String>,
/// Focused index within `autocomplete_candidates`.
pub autocomplete_idx: usize,
/// Whether the autocomplete dropdown is visible.
pub autocomplete_visible: bool,
/// Which kind of autocomplete is active.
pub autocomplete_kind: AutocompleteKind,
/// Byte offset of the `@` character that triggered file mention autocomplete.
pub mention_start: usize,
/// Optional path to a persistent history file.
pub history_file: Option<PathBuf>,
}
impl InputState {
/// Create an empty input state.
pub fn new() -> Self {
InputState {
buffer: String::new(),
cursor: 0,
history: Vec::new(),
history_idx: None,
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_idx = 0;
self.autocomplete_kind = AutocompleteKind::Command;
self.mention_start = 0;
}
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`.
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_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.
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`.
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
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).
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.
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 => {
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
}
/// Tab-complete: open dropdown or cycle forward.
pub fn tab_complete(&mut self) {
if self.autocomplete_visible {
self.cycle_autocomplete(true);
} else {
self.open_autocomplete();
}
}
/// Insert a character at the cursor position.
pub fn insert(&mut self, c: char) {
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
/// 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);
}
}
/// Submit the current buffer and return the submitted text.
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.
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 => {}
}
}
}
impl Default for InputState {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Overlay enum
// ---------------------------------------------------------------------------
/// Which modal overlay, if any, is currently shown over the main TUI view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
/// No overlay; the main chat view is shown.
None,
/// Key bindings help screen.
Help,
/// Settings/configuration panel.
Settings,
/// Background bash job viewer.
Bash,
/// "Are you sure you want to quit?" confirmation.
QuitConfirm,
/// Raw key-code input capture (for binding custom keys).
KeyInput,
/// Inline editor (opened via `/edit`).
Editor,
/// Reasoning effort level selector.
Effort,
/// MCP server management panel.
Mcp,
/// Task list overlay.
Todo,
/// Session rewind / history scrubber.
Rewind,
/// Learning / lesson management panel.
Learning,
/// Token usage statistics panel.
Usage,
/// Generic loading spinner overlay.
Loading,
/// Model selector dropdown.
ModelSelector,
/// "Clear conversation?" confirmation.
ClearConfirm,
}
impl Overlay {
/// Human-readable name for this overlay variant.
pub fn as_str(&self) -> &'static str {
match self {
Overlay::None => "none",
Overlay::Help => "help",
Overlay::Settings => "settings",
Overlay::Bash => "bash",
Overlay::QuitConfirm => "quit_confirm",
Overlay::KeyInput => "key_input",
Overlay::Editor => "editor",
Overlay::Effort => "effort",
Overlay::Mcp => "mcp",
Overlay::Todo => "todo",
Overlay::Rewind => "rewind",
Overlay::Learning => "learning",
Overlay::Usage => "usage",
Overlay::Loading => "loading",
Overlay::ModelSelector => "model_selector",
Overlay::ClearConfirm => "clear_confirm",
}
}
/// Whether any overlay (i.e. anything other than `None`) is active.
pub fn is_active(self) -> bool {
!matches!(self, Overlay::None)
}
}
impl std::fmt::Display for Overlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
// ---------------------------------------------------------------------------
// MiscState — overlay, toasts, flags, tick, editor
// ---------------------------------------------------------------------------
/// The "miscellaneous" slice of app state.
#[derive(Debug, Clone)]
pub struct MiscState {
/// Currently active modal overlay (None = main chat view).
pub overlay: Overlay,
/// Active toast notifications.
pub toasts: Vec<Toast>,
/// Timestamp (ms) of the last staleness sweep for lesson cache.
pub last_staleness_sweep_ms: i64,
/// Whether the agent is currently "thinking".
pub thinking: bool,
/// Current LLM reasoning effort level (1-5).
pub effort_level: usize,
/// Currently focused index in list-type overlays.
pub selected_index: usize,
/// Optional inline editor state.
pub editor: Option<EditorState>,
/// Whether the API connection is established.
pub api_connected: bool,
/// Monotonically increasing tick count, incremented each render frame.
pub tick_count: u64,
/// Cached content of the TODO file.
pub todo_content: String,
/// Whether a lesson background task is currently running.
pub lesson_running: bool,
/// Text waiting to be written to the system clipboard.
pub pending_clipboard_copy: Option<String>,
}
impl MiscState {
/// Create a fresh `MiscState` with no overlay, no toasts.
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,
tick_count: 0,
todo_content: String::new(),
lesson_running: false,
pending_clipboard_copy: None,
}
}
/// Append a toast notification to the active list.
pub fn push_toast(&mut self, toast: Toast) {
self.toasts.push(toast);
}
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> {
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
self.toasts.retain(|t| !t.expired(now_ms));
expired
}
}
impl Default for MiscState {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// EditorState — used by the Editor overlay
// ---------------------------------------------------------------------------
/// Simple inline editor state for the TUI.
#[derive(Debug, Clone)]
pub struct EditorState {
/// Path to the file being edited.
pub path: PathBuf,
/// Current buffer content.
pub content: String,
/// Cursor position (byte offset).
pub cursor: usize,
}
impl EditorState {
/// Create a new editor state for the given path.
pub fn new(path: PathBuf, content: String) -> Self {
let cursor = content.len();
EditorState {
path,
content,
cursor,
}
}
/// Return the full buffer content.
pub fn as_string(&self) -> String {
self.content.clone()
}
/// Delete one character to the left of the cursor.
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.content.remove(self.cursor);
}
}
}
// ---------------------------------------------------------------------------
// AgentState + SimpleAgent + SimpleWorkflowEngine (workflow display)
// ---------------------------------------------------------------------------
/// Simplified agent lifecycle state for TUI display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentState {
Idle,
Running,
Completed,
Failed,
}
/// A single agent entry in the workflow sidebar.
#[derive(Debug, Clone)]
pub struct SimpleAgent {
/// Agent display name.
pub name: String,
/// Current lifecycle state.
pub state: AgentState,
/// Millisecond timestamp when the agent started.
pub started_at: Option<i64>,
/// Millisecond timestamp when the agent completed.
pub completed_at: Option<i64>,
/// Optional error message if the agent failed.
pub error: Option<String>,
/// Optional progress text (current tool, step description).
pub progress: Option<String>,
}
impl SimpleAgent {
/// Create a new agent with the given name.
pub fn new(name: String) -> Self {
SimpleAgent {
name,
state: AgentState::Idle,
started_at: None,
completed_at: None,
error: None,
progress: None,
}
}
}
/// Simplified workflow engine state for TUI display.
#[derive(Debug, Clone)]
pub struct SimpleWorkflowEngine {
/// Active agents in the workflow.
pub agents: Vec<SimpleAgent>,
/// Summary findings produced by completed agents.
pub findings: Vec<String>,
}
impl SimpleWorkflowEngine {
/// Create an empty workflow engine state.
pub fn new() -> Self {
SimpleWorkflowEngine {
agents: Vec::new(),
findings: Vec::new(),
}
}
}
impl Default for SimpleWorkflowEngine {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Effort levels (for effort overlay)
// ---------------------------------------------------------------------------
/// Name of each reasoning-effort tier.
pub const EFFORT_LEVELS: &[&str] = &[
"Auto — let the provider decide",
"Low — fast, minimal reasoning",
"Medium — balanced speed & reasoning",
"High — thorough reasoning",
"Maximum — deep analysis",
];
/// Return the current effort index from state.
pub fn current_effort(state: &AppStateRest) -> usize {
state.misc.effort_level.saturating_sub(1).min(EFFORT_LEVELS.len().saturating_sub(1))
}
/// Cycle effort level up or down.
pub fn cycle_effort(state: &mut AppStateRest, forward: bool) {
let n = EFFORT_LEVELS.len();
if forward {
state.misc.effort_level = (state.misc.effort_level % n) + 1;
} else {
state.misc.effort_level = if state.misc.effort_level <= 1 {
n
} else {
state.misc.effort_level - 1
};
}
state.mark_dirty();
}
// ---------------------------------------------------------------------------
// Learning item types (for learning overlay)
// ---------------------------------------------------------------------------
/// A lesson entry displayed in the Learning overlay.
#[derive(Debug, Clone)]
pub enum LearningItem {
/// A newly-generated lesson pending user approval.
Pending {
name: String,
content: String,
scope: String,
confidence: f64,
},
/// A lesson that has been accepted and stored.
Stored {
name: String,
content: String,
lifecycle: String,
scope: String,
description: String,
},
}
/// Return learning items from state.
///
/// Reads lesson markdown files from the `lessons/` subdirectory
/// under the memory directory.
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
let lessons_dir = state.memory_dir.join("lessons");
if !lessons_dir.exists() {
return Vec::new();
}
let mut items = Vec::new();
if let Ok(entries) = std::fs::read_dir(&lessons_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
if let Ok(content) = std::fs::read_to_string(&path) {
items.push(LearningItem::Stored {
name: path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
content,
lifecycle: "filesystem".to_string(),
scope: "filesystem".to_string(),
description: String::new(),
});
}
}
}
}
items
}
/// Cycle the selected index within bounds.
pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize {
if n == 0 {
return 0;
}
if forward {
(current + 1) % n
} else {
if current == 0 { n - 1 } else { current - 1 }
}
}
// ---------------------------------------------------------------------------
// Rewind helpers
// ---------------------------------------------------------------------------
/// Return the number of rewind points available.
pub fn rewind_count(state: &AppStateRest) -> usize {
state.transcript_cache.messages.len()
}
// ---------------------------------------------------------------------------
// Context window helpers
// ---------------------------------------------------------------------------
/// Resolve the window size for context window management.
///
/// Uses the model name from settings to determine max context window,
/// falling back to settings-configured max or 128k default.
pub fn resolve_context_window(
_app_config: &zesdex_domain::cms::AppConfig,
settings: &zesdex_domain::cms::Settings,
) -> usize {
// Use configured max tokens from settings, or default to 128k
if let Some(max) = settings.max_tokens {
if max > 0 {
return max as usize;
}
}
128_000
}
/// Count tokens using tiktoken, fall back to character estimation.
pub fn count_tokens(text: &str) -> usize {
// Try tiktoken for accurate counting
if let Ok(bpe) = tiktoken_rs::cl100k_base() {
return bpe.encode_with_special_tokens(text).len();
}
// Fallback: ~4 chars per token
text.len().div_ceil(4)
}
// ---------------------------------------------------------------------------
// AppStateRest — the single source-of-truth TUI state
// ---------------------------------------------------------------------------
/// The single source-of-truth state struct for the TUI interface.
///
/// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`).
/// Read-only from every `view/*` render function.
#[derive(Clone)]
pub struct AppStateRest {
/// Persistent user settings.
pub settings: Settings,
/// Per-project app configuration.
pub app_config: AppConfig,
/// Absolute paths to each open workspace root directory.
pub workspace_roots: Vec<PathBuf>,
/// Unique session identifier.
pub session_id: String,
/// Path to the session's data directory.
pub session_dir: PathBuf,
/// Path to the session memory directory.
pub memory_dir: PathBuf,
/// Path to the git worktrees directory.
pub worktrees_dir: PathBuf,
/// Shared async cache of directory listings.
pub dir_cache: Arc<tokio::sync::RwLock<DirCache>>,
/// Shared workspace file-path index for `@file` mention autocomplete.
pub mention_index: MentionIndex,
/// Optional per-session runtime state.
pub session_runtime: Option<SessionRuntime>,
/// Ring buffer of recent chat messages for the transcript pane.
pub transcript_cache: TranscriptCache,
/// Viewport scroll offset tracker.
pub scroll: ScrollState,
/// Chat input buffer, cursor, history, and autocomplete.
pub input: InputState,
/// Miscellaneous state: overlay, toasts, flags, editor, tick.
pub misc: MiscState,
/// Queue of events emitted by the running agent turn.
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
/// Whether an agent turn is currently in flight.
pub turn_in_flight_flag: Arc<Mutex<bool>>,
/// Atomic flag set when the user aborts the current turn.
pub abort_flag: Arc<AtomicBool>,
/// Simplified workflow engine state for display.
pub workflow_engine: SimpleWorkflowEngine,
/// Whether the state has been modified since the last render sweep.
pub dirty: bool,
/// Whether the application has been requested to quit.
pub quit: bool,
/// Cached help text content.
pub help_text: &'static str,
}
/// Default help text shown in the Help overlay.
pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI — Keyboard Shortcuts
─── General ───
Ctrl+C Quit confirm
Ctrl+D Close overlay
Ctrl+Y Copy last assistant message
Esc Abort turn / Close overlay
Tab Autocomplete
─── Navigation ───
↑ / ↓ History browse / Overlay navigate
Ctrl+↑/↓ Scroll transcript
PgUp / PgDown Scroll transcript
Enter Submit / Select autocomplete
─── Overlays ───
/help Show this help
/settings Open settings overlay
/todo Open tasks (todo) overlay
/usage Open usage statistics
/bash Open bash jobs overlay
/mcp Open MCP server management
/model Open model selector
/compact Compact conversation
/clear Clear transcript
/rewind Rewind conversation history
─── Editor Mode ───
/edit <path> Open file for inline editing
Ctrl+S Save changes
Esc Dismiss editor
"#;
impl Default for AppStateRest {
fn default() -> Self {
AppStateRest {
settings: Settings::default(),
app_config: AppConfig::default(),
workspace_roots: Vec::new(),
session_id: String::new(),
session_dir: PathBuf::new(),
memory_dir: PathBuf::new(),
worktrees_dir: PathBuf::new(),
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
mention_index: MentionIndex::new(),
session_runtime: None,
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
turn_events: Arc::new(Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(Mutex::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)),
workflow_engine: SimpleWorkflowEngine::new(),
dirty: true,
quit: false,
help_text: DEFAULT_HELP_TEXT,
}
}
}
impl AppStateRest {
/// Construct initial TUI state.
pub fn new(
workspace_roots: Vec<PathBuf>,
session_dir: &std::path::Path,
memory_dir: PathBuf,
) -> Self {
let settings = Settings::default();
let app_config = AppConfig::default();
let worktrees_dir = memory_dir
.parent()
.unwrap_or(&memory_dir)
.join("worktrees");
let session_id = session_dir.file_name().map_or_else(
|| {
warn!("[state] session_dir has no file_name, using empty session_id");
String::new()
},
|n| n.to_string_lossy().to_string(),
);
AppStateRest {
settings,
app_config,
workspace_roots,
session_id,
session_dir: session_dir.to_path_buf(),
memory_dir: memory_dir.clone(),
worktrees_dir,
turn_events: Arc::new(Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(Mutex::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)),
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
mention_index: MentionIndex::new(),
session_runtime: None,
workflow_engine: SimpleWorkflowEngine::new(),
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
dirty: true,
quit: false,
help_text: DEFAULT_HELP_TEXT,
}
}
/// Whether an agent turn is currently running.
pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight_flag.lock().map_or_else(
|_| {
warn!("[state] turn_in_flight mutex poisoned");
false
},
|g| *g,
)
}
/// Append a message to the transcript.
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.
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
/// Queue a toast notification.
pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast);
self.mark_dirty();
}
/// Push an info toast.
pub fn toast_info(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Info, msg.into()));
}
/// Push a success toast.
pub fn toast_success(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Success, msg.into()));
}
/// Push a warning toast.
pub fn toast_warning(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Warning, msg.into()));
}
/// Push an error toast.
pub fn toast_error(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Error, msg.into()));
}
/// Persist settings to disk.
pub fn save_settings(&self) {
if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) {
let repo = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new();
use zesdex_domain::SettingsRepository;
if let Err(e) = repo.save(&store_dir, &self.settings) {
tracing::warn!("Failed to save settings: {e}");
}
}
}
/// Resolve the base directory for session stores.
pub fn store_base_dir(&self) -> PathBuf {
self.session_dir
.parent()
.and_then(|p| p.parent())
.map_or_else(
|| {
warn!("[state] no grandparent, using session_dir");
self.session_dir.clone()
},
std::path::Path::to_path_buf,
)
}
}