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
+88
View File
@@ -0,0 +1,88 @@
//! Git diff output helper for the diff overlay.
//!
//! Extracted to its own module so the action dispatcher stays focused on
//! state mutation logic rather than shell-out I/O.
use std::process::Command;
/// Run `git diff` and return the output for display in the diff overlay.
///
/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back
/// to `git diff` if HEAD has no commits yet. Returns a summary of what
/// changed with coloured +/- markers.
pub fn git_diff_output(workspace_roots: &[std::path::PathBuf]) -> String {
let root = workspace_roots
.first()
.cloned()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let mut output = String::new();
// Try diff against HEAD
let head_result = Command::new("git")
.args(["diff", "HEAD"])
.current_dir(&root)
.output();
match head_result {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Changes (against HEAD) ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
Err(_) => {
// Fallback: no HEAD yet (new repo)
let diff_result = Command::new("git")
.args(["diff"])
.current_dir(&root)
.output();
if let Ok(out) = diff_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Unstaged Changes ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
}
}
// Get staged changes too
let staged_result = Command::new("git")
.args(["diff", "--cached"])
.current_dir(&root)
.output();
if let Ok(out) = staged_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Staged Changes ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
// Also get status summary
let status_result = Command::new("git")
.args(["status", "--short"])
.current_dir(&root)
.output();
if let Ok(out) = status_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Summary ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
if output.is_empty() {
output = "No changes detected in the working tree.".to_string();
}
output
}
+192
View File
@@ -0,0 +1,192 @@
//! Extracted action handlers for complex `Action` variants.
//!
//! The `apply_action` dispatcher delegates to these functions for variants
//! whose logic exceeds a few lines, keeping the match statement compact
//! and readable.
use crate::state::{AppStateRest, ChatMessageDisplay};
use tracing::debug;
use zesdex_domain::core::Role;
// ---------------------------------------------------------------------------
// Tick handler — drains turn events from the shared queue
// ---------------------------------------------------------------------------
/// Process the `Action::Tick` event: increment tick counter, drain all
/// queued `TurnEvent` values from the shared queue, and apply each one
/// to the application state.
pub fn handle_tick(state: &mut AppStateRest) {
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
// Drain turn events — collect into a local Vec first to avoid holding
// the mutex guard across the entire dispatch loop.
let events: Vec<zesdex_infrastructure::TurnEvent> = state
.turn_events
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
let had_events = !events.is_empty();
for event in events {
apply_turn_event(state, event);
}
// Mark dirty when there were events OR a turn is in-flight (spinner
// animation); skip the flag when idle to avoid useless re-renders.
if had_events || state.turn_in_flight() {
state.mark_dirty();
}
}
// ---------------------------------------------------------------------------
// TurnEvent dispatcher
// ---------------------------------------------------------------------------
/// Apply a single `TurnEvent` to `AppStateRest`. Extracted from the
/// `Tick` handler so each event variant's logic is self-contained.
fn apply_turn_event(state: &mut AppStateRest, event: zesdex_infrastructure::TurnEvent) {
match event {
zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => {
if kind == "hive_mind_converged" {
if let Some(ref mut rt) = state.session_runtime {
rt.hive_mind_converged = true;
}
} else {
state
.push_transcript(ChatMessageDisplay::new(Role::System, message));
}
}
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
state.push_transcript(ChatMessageDisplay::new(
msg.role,
msg.content.unwrap_or_default(),
));
}
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
state.push_transcript(ChatMessageDisplay::new(Role::Tool, output));
}
zesdex_infrastructure::TurnEvent::Usage {
tokens_in,
tokens_out,
} => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
rt.usage.last_tokens_in = tokens_in;
rt.usage.last_tokens_out = tokens_out;
rt.usage.api_calls = rt.usage.api_calls.saturating_add(1);
}
}
zesdex_infrastructure::TurnEvent::Error(msg) => {
state.toast_error(msg);
}
zesdex_infrastructure::TurnEvent::StreamStart => {
state
.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new()));
}
zesdex_infrastructure::TurnEvent::StreamToken(text) => {
state.append_to_last_transcript(&text, false);
}
zesdex_infrastructure::TurnEvent::StreamReasoning(text) => {
state.append_to_last_transcript(&text, true);
}
zesdex_infrastructure::TurnEvent::StreamDone(_msg) => {
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
if let Some(ref mut rt) = state.session_runtime {
rt.messages = msgs;
}
}
zesdex_infrastructure::TurnEvent::TodoUpdate(content) => {
state.misc.todo_content = content;
state.toast_success("TODO list updated.");
}
zesdex_infrastructure::TurnEvent::PlanUpdate(content) => {
state.misc.plan_content = content;
state.toast_success("Project plan updated.");
}
zesdex_infrastructure::TurnEvent::Done => {
state
.turn_in_flight_flag
.store(false, std::sync::atomic::Ordering::SeqCst);
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status,
} => {
apply_workflow_update(state, agent_id, agent_name, status);
}
_ => {
debug!("unhandled turn event variant");
state.dirty = true;
}
}
}
// ---------------------------------------------------------------------------
// Workflow-agent update handler
// ---------------------------------------------------------------------------
/// Update the workflow sidebar state from a `WorkflowAgentUpdate` event.
fn apply_workflow_update(
state: &mut AppStateRest,
agent_id: String,
agent_name: String,
status: zesdex_infrastructure::AgentStatus,
) {
let idx = state
.workflow_engine
.agents
.iter()
.position(|a| a.name == agent_id);
match status {
zesdex_infrastructure::AgentStatus::Pending => {
if idx.is_none() {
state
.workflow_engine
.agents
.push(crate::state::SimpleAgent::with_display(agent_id, agent_name));
}
}
zesdex_infrastructure::AgentStatus::Running => {
let now = chrono::Utc::now().timestamp_millis();
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Running;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].started_at = Some(now);
} else {
let mut agent =
crate::state::SimpleAgent::with_display(agent_id, agent_name);
agent.state = crate::state::AgentState::Running;
agent.started_at = Some(now);
state.workflow_engine.agents.push(agent);
}
}
zesdex_infrastructure::AgentStatus::Completed => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Completed;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].completed_at =
Some(chrono::Utc::now().timestamp_millis());
}
}
zesdex_infrastructure::AgentStatus::Failed(msg) => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Failed;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].error = Some(msg);
}
}
zesdex_infrastructure::AgentStatus::Cancelled => {
if let Some(i) = idx {
state.workflow_engine.agents.remove(i);
}
}
}
state.dirty = true;
}
+240
View File
@@ -0,0 +1,240 @@
//! The `Action` enum — a single well-typed event in the TUI, produced by
//! key input and applied to `AppStateRest` by the event loop.
//!
//! # Flow
//! `controller::input::handle_key` returns `Vec<Action>` \u{2192} the event loop
//! calls `apply_action(&mut state, action)` for each one \u{2192} state is mutated
//! in place.
//!
//! # Organisation
//!
//! ```text
//! action/
//! ├── mod.rs — Action enum + apply_action dispatcher
//! ├── handlers.rs — Extracted handlers for complex action variants
//! └── git.rs — git diff output helper (shell-out I/O)
//! ```
//!
//! # Design
//! Every state mutation funnels through this single chokepoint so the view
//! layer never mutates state directly and the controller never needs to know
//! *how* state is updated \u{2014} only *what* action to produce.
mod git;
mod handlers;
use tracing::debug;
use crate::state::Overlay;
use crate::state::{AppStateRest, ChatMessageDisplay};
use zesdex_domain::core::Role;
/// A single well-typed event in the TUI that mutates `AppStateRest`.
#[derive(Debug, Clone)]
pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight,
/// Navigate up through command history.
HistoryUp,
/// Navigate down through command history.
HistoryDown,
/// Scroll the transcript pane up.
ScrollUp,
/// Scroll the transcript pane down.
ScrollDown,
/// Open a named overlay.
OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote {
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
kind: String,
/// The message text to display.
message: String,
},
/// Show the quit-confirmation overlay.
QuitConfirm,
/// Terminal resize event.
Resize(u16, u16),
/// Periodic timer tick — drains queued `TurnEvent`s.
Tick,
/// Accept a lesson (learned behaviour pattern) by name.
LessonAccept { name: String },
/// Reject a lesson by name.
LessonReject { name: String },
/// Delete a previously stored lesson by name.
LessonDelete { name: String },
/// Start the OAuth device-code login flow for a named provider.
StartOAuth { provider: String },
/// Open the inline file editor for `path`.
OpenEditor { path: String },
/// Register a new MCP server by name and shell command.
McpAdd { name: String, command: String },
/// Open the model-picker overlay.
ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
/// Show the git diff preview overlay.
ShowDiff,
/// Scroll the diff overlay by `amount` lines (negative = up, positive = down).
DiffScroll(i32),
}
/// Apply an `Action` to `AppStateRest`.
///
/// Flow: pattern-match the variant \u{2192} mutate state in place.
/// This is the single chokepoint for all state mutations.
///
/// Return: nothing; `state` is mutated in place.
#[tracing::instrument(skip(state))]
pub fn apply_action(state: &mut AppStateRest, action: Action) {
debug!("apply_action: {:?}", action);
match action {
// ── Lifecycle ─────────────────────────────────────────────────
Action::ForceQuit => state.quit = true,
Action::QuitConfirm => {
state.misc.overlay = Overlay::QuitConfirm;
state.mark_dirty();
}
Action::Resize(w, _h) => {
if state.last_render_width != w {
state.transcript_cache.dirty = true;
state.last_render_width = w;
}
state.mark_dirty();
}
// ── Timer tick (delegates to extracted handler) ────────────────
Action::Tick => handlers::handle_tick(state),
// ── Input ──────────────────────────────────────────────────────
Action::SubmitInput(text) => {
state
.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
state.input.submit();
crate::turn::spawn_agent_turn(state, text);
state.token_count_dirty = true;
state.mark_dirty();
}
Action::DeleteChar => {
state.input.delete_left();
state.mark_dirty();
}
Action::DeleteCharRight => {
state.input.delete_right();
state.mark_dirty();
}
Action::CursorLeft => {
state.input.cursor = state.input.cursor.saturating_sub(1);
state.mark_dirty();
}
Action::CursorRight => {
if state.input.cursor < state.input.buffer.len() {
state.input.cursor += 1;
}
state.mark_dirty();
}
Action::HistoryUp => {
state.input.history_up();
state.mark_dirty();
}
Action::HistoryDown => {
state.input.history_down();
state.mark_dirty();
}
// ── Scroll ─────────────────────────────────────────────────────
Action::ScrollUp => {
state.scroll.scroll_up(3);
state.mark_dirty();
}
Action::ScrollDown => {
state.scroll.scroll_down(3);
state.mark_dirty();
}
// ── Overlay ────────────────────────────────────────────────────
Action::OpenOverlay(overlay) => {
state.misc.overlay = overlay;
state.mark_dirty();
}
Action::CloseOverlay => {
state.misc.overlay = Overlay::None;
state.mark_dirty();
}
Action::SystemNote { kind: _, message } => {
state
.push_transcript(ChatMessageDisplay::new(Role::System, message));
}
Action::ModelList => {
state.misc.overlay = Overlay::ModelSelector;
state.mark_dirty();
}
Action::OpenEditor { path } => {
let content = std::fs::read_to_string(&path).unwrap_or_default();
state.misc.editor = Some(crate::state::EditorState::new(
std::path::PathBuf::from(&path),
content,
));
state.misc.overlay = Overlay::Editor;
state.mark_dirty();
}
// ── Lessons ────────────────────────────────────────────────────
Action::LessonAccept { name } => state.toast_info(format!("Lesson accepted: {name}")),
Action::LessonReject { name } => state.toast_info(format!("Lesson rejected: {name}")),
Action::LessonDelete { name } => state.toast_info(format!("Lesson deleted: {name}")),
// ── Auth ───────────────────────────────────────────────────────
Action::StartOAuth { provider } => {
state.toast_info(format!("OAuth login started for {provider}"))
}
// ── MCP ────────────────────────────────────────────────────────
Action::McpAdd { name, command } => {
state.toast_info(format!("MCP server added: {name} ({command})"))
}
// ── Turn control ────────────────────────────────────────────────
Action::AbortTurn => {
state
.abort_flag
.store(true, std::sync::atomic::Ordering::SeqCst);
state.toast_info("Aborting current turn...".to_string());
}
Action::Compact => state.toast_info("Compacting conversation...".to_string()),
// ── Diff ───────────────────────────────────────────────────────
Action::ShowDiff => {
let diff_output = git::git_diff_output(&state.workspace_roots);
state.misc.diff_content = diff_output;
state.misc.diff_scroll = 0;
state.misc.overlay = Overlay::Diff;
state.mark_dirty();
}
Action::DiffScroll(amount) => {
let max_scroll = state
.misc
.diff_content
.lines()
.count()
.saturating_sub(1);
let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize;
state.misc.diff_scroll = new_scroll.min(max_scroll);
state.mark_dirty();
}
}
}