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,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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user