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:
asepharyana
2026-07-21 06:42:53 +07:00
parent 802346f909
commit 8c58faf292
25 changed files with 2594 additions and 2158 deletions
+318
View File
@@ -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,
)
}
}