- fix(view): token streaming kini benar-benar tampil — sebelumnya cache display_lines tidak pernah di-rebuild saat pesan terakhir berubah (msg_count == cached_count), jadi teks AI streaming tidak pernah muncul sampai pesan baru/resize - perf(view): streaming kini hanya re-render pesan TERAKHIR (splice di batas cached_last_start) → O(konten baru) per token, bukan O(seluruh history); guard cached_last_len mencegah re-render pada frame spinner tanpa token baru - perf(run): skip chrono::Utc::now() + drain toasts saat tidak ada toast - perf(misc): drain_expired_toasts tidak lagi clone seluruh daftar toast - perf(view): render_toasts fast-path saat toasts kosong
340 lines
13 KiB
Rust
340 lines
13 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 `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 ratatui::text::Line;
|
|
use zesdex_domain::cms::{AppConfig, Settings};
|
|
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,
|
|
resolve_context_window, rewind_count, LearningItem, EFFORT_LEVELS,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
/// Cache index where the last message's rendered lines begin. Used to
|
|
/// splice streaming updates (only re-render the trailing message).
|
|
pub cached_last_start: usize,
|
|
/// Content+reasoning byte length of the last message when it was last
|
|
/// rendered. Guards the streaming branch from re-rendering on ticks
|
|
/// where no new token arrived (spinner-only frames).
|
|
pub cached_last_len: 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,
|
|
cached_last_start: 0,
|
|
cached_last_len: 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,
|
|
cached_last_start: 0,
|
|
cached_last_len: 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,
|
|
)
|
|
}
|
|
}
|