feat(tui): introduce comprehensive state management for TUI interface
- Add AppStateRest as the central state struct for managing TUI state. - Implement InputState for handling user input, autocomplete, and history. - Create MiscState to manage overlays, notifications, and editor state. - Introduce ScrollState for viewport scrolling functionality. - Develop TranscriptCache for efficient message rendering in the chat pane. - Implement SimpleAgent and SimpleWorkflowEngine for agent lifecycle management. - Add helper functions for managing effort levels and token counting. - Organize state-related modules for better maintainability and clarity.
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
//! 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 `action::apply_action` and read by `view/` every render frame.
|
||||
//!
|
||||
//! # Organisation
|
||||
//!
|
||||
//! ```text
|
||||
//! state/
|
||||
//! ├── mod.rs — AppStateRest (the central struct) + re-exports
|
||||
//! ├── input.rs — InputState, AutocompleteKind
|
||||
//! ├── transcript.rs — TranscriptCache, ChatMessageDisplay
|
||||
//! ├── scroll.rs — ScrollState
|
||||
//! ├── misc.rs — MiscState, EditorState, Overlay
|
||||
//! ├── workflow.rs — SimpleAgent, AgentState, SimpleWorkflowEngine
|
||||
//! └── helpers.rs — Standalone functions operating on AppStateRest
|
||||
//! ```
|
||||
//!
|
||||
//! # Flow
|
||||
//! Construction in `run.rs::create_local_session` \u{2192} mutated by
|
||||
//! `action::apply_action` \u{2192} read-only in every `view/*::draw*` function.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
|
||||
use zesdex_domain::cms::{AppConfig, Settings};
|
||||
use ratatui::text::Line;
|
||||
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
|
||||
|
||||
pub mod helpers;
|
||||
pub mod input;
|
||||
pub mod misc;
|
||||
pub mod scroll;
|
||||
pub mod transcript;
|
||||
pub mod workflow;
|
||||
|
||||
// Re-export all public types from sub-modules at the `state` level so
|
||||
// consumers that previously used `crate::state::InputState` etc. still work.
|
||||
pub use input::{AutocompleteKind, InputState};
|
||||
pub use misc::{EditorState, MiscState, Overlay};
|
||||
pub use scroll::ScrollState;
|
||||
pub use transcript::{ChatMessageDisplay, TranscriptCache};
|
||||
pub use workflow::{AgentState, SimpleAgent, SimpleWorkflowEngine};
|
||||
|
||||
// Re-export the most commonly used helpers at the `state` level.
|
||||
pub use helpers::{
|
||||
count_tokens, current_effort, cycle_effort, cycle_selected_index, get_learning_items,
|
||||
rewind_count, resolve_context_window, EFFORT_LEVELS, LearningItem,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<std::sync::Mutex<VecDeque<TurnEvent>>>,
|
||||
/// Whether an agent turn is currently in flight.
|
||||
/// Uses AtomicBool for lock-free check from render loop.
|
||||
pub turn_in_flight_flag: Arc<AtomicBool>,
|
||||
/// Cached display lines for the chat transcript panel.
|
||||
pub display_lines_cache: Vec<Line<'static>>,
|
||||
/// Cached token count for the current message history.
|
||||
pub cached_token_count: usize,
|
||||
/// Whether the token count cache is stale and needs recalculation.
|
||||
pub token_count_dirty: bool,
|
||||
/// Terminal width at the time of the last display_lines_cache rebuild.
|
||||
pub last_render_width: u16,
|
||||
/// Number of messages that were in the cache when it was last built.
|
||||
pub cached_msg_count: usize,
|
||||
/// Terminal width at the time of the last full cache build.
|
||||
pub render_width_at_cache: u16,
|
||||
/// 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 \u{2014} Keyboard Shortcuts
|
||||
|
||||
\u{2500}\u{2500}\u{2500} General \u{2500}\u{2500}\u{2500}
|
||||
Ctrl+C Quit confirm
|
||||
Ctrl+D Close overlay
|
||||
Ctrl+Y Copy last assistant message
|
||||
Esc Abort turn / Close overlay
|
||||
Tab Autocomplete
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Navigation \u{2500}\u{2500}\u{2500}
|
||||
\u{2191} / \u{2193} History browse / Overlay navigate
|
||||
Ctrl+\u{2191}/\u{2193} Scroll transcript
|
||||
PgUp / PgDown Scroll transcript
|
||||
Enter Submit / Select autocomplete
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Overlays \u{2500}\u{2500}\u{2500}
|
||||
/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
|
||||
|
||||
\u{2500}\u{2500}\u{2500} Editor Mode \u{2500}\u{2500}\u{2500}
|
||||
/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(std::sync::Mutex::new(VecDeque::new())),
|
||||
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
|
||||
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||
workflow_engine: SimpleWorkflowEngine::new(),
|
||||
dirty: true,
|
||||
quit: false,
|
||||
help_text: DEFAULT_HELP_TEXT,
|
||||
display_lines_cache: Vec::new(),
|
||||
cached_token_count: 0,
|
||||
token_count_dirty: true,
|
||||
last_render_width: 0,
|
||||
cached_msg_count: 0,
|
||||
render_width_at_cache: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(std::sync::Mutex::new(VecDeque::new())),
|
||||
turn_in_flight_flag: Arc::new(AtomicBool::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: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||||
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,
|
||||
display_lines_cache: Vec::new(),
|
||||
cached_token_count: 0,
|
||||
token_count_dirty: true,
|
||||
last_render_width: 0,
|
||||
cached_msg_count: 0,
|
||||
render_width_at_cache: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an agent turn is currently running.
|
||||
/// Uses lock-free AtomicBool load \u{2014} safe to call every render frame.
|
||||
pub fn turn_in_flight(&self) -> bool {
|
||||
self.turn_in_flight_flag.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Append a message to the transcript.
|
||||
/// Eviction is O(1) via VecDeque::pop_front.
|
||||
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||||
self.transcript_cache.push(msg);
|
||||
self.token_count_dirty = true;
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Append text to the last assistant message in the transcript, if one exists.
|
||||
pub fn append_to_last_transcript(&mut self, text: &str, is_reasoning: bool) {
|
||||
self.transcript_cache.append_to_last(text, is_reasoning);
|
||||
if self.transcript_cache.dirty {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user