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
-490
View File
@@ -1,490 +0,0 @@
//! 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>` → the event loop
//! calls `apply_action(&mut state, action)` for each one → state is mutated
//! in place.
//!
//! # 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 — only *what* action to produce.
use crate::state::Overlay;
use std::process::Command;
use tracing::debug;
/// 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.
DiffScroll(i32),
}
/// Apply an `Action` to `AppStateRest`.
///
/// Flow: pattern-match the variant → 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 crate::state::AppStateRest, action: Action) {
debug!("apply_action: {:?}", action);
match action {
Action::ForceQuit => {
state.quit = true;
}
Action::QuitConfirm => {
state.misc.overlay = crate::state::Overlay::QuitConfirm;
state.mark_dirty();
}
Action::Resize(w, _h) => {
// Invalidate display cache so pre_render_chat rebuilds at new width.
if state.last_render_width != w {
state.transcript_cache.dirty = true;
state.last_render_width = w;
}
state.mark_dirty();
}
Action::Tick => {
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
// Drain turn events from the shared queue — collect events first,
// then mutate state, to avoid borrow conflicts with the mutex guard.
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 {
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(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
message,
));
}
}
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
msg.role,
msg.content.unwrap_or_default(),
));
}
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::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(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::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) => {
// The final message is already accumulated in the transcript.
// We might want to trigger a save or something here, but for UI, we just mark dirty.
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);
// Mark dirty so spinner disappears
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status,
} => {
// Find existing agent by ID, or create new one
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 => {
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(chrono::Utc::now().timestamp_millis());
} else {
let mut agent =
crate::state::SimpleAgent::with_display(
agent_id,
agent_name,
);
agent.state = crate::state::AgentState::Running;
agent.started_at =
Some(chrono::Utc::now().timestamp_millis());
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;
}
_ => {
debug!("unhandled turn event variant");
state.dirty = true;
}
}
}
// Mark dirty only when there's something that changed:
// - new events were processed (messages, errors, etc.)
// - turn is in-flight (spinner needs to animate each tick)
// When idle with no events, skip the dirty flag to avoid useless renders.
if had_events || state.turn_in_flight() {
state.mark_dirty();
}
}
Action::SubmitInput(text) => {
// Push user message to transcript display
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::User,
text.clone(),
));
state.input.submit();
// Spawn real agent turn on a background thread
crate::turn::spawn_agent_turn(state, text);
// Invalidate token count cache since we added a message
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();
}
Action::ScrollUp => {
state.scroll.scroll_up(3);
state.mark_dirty();
}
Action::ScrollDown => {
state.scroll.scroll_down(3);
state.mark_dirty();
}
Action::OpenOverlay(overlay) => {
state.misc.overlay = overlay;
state.mark_dirty();
}
Action::CloseOverlay => {
state.misc.overlay = crate::state::Overlay::None;
state.mark_dirty();
}
Action::SystemNote { kind: _, message } => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
message,
));
}
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}"));
}
Action::StartOAuth { provider } => {
state.toast_info(format!("OAuth login started for {provider}"));
}
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 = crate::state::Overlay::Editor;
state.mark_dirty();
}
Action::McpAdd { name, command } => {
state.toast_info(format!("MCP server added: {name} ({command})"));
}
Action::ModelList => {
state.misc.overlay = crate::state::Overlay::ModelSelector;
state.mark_dirty();
}
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());
}
Action::ShowDiff => {
// Run git diff to get current changes
let diff_output = git_diff_output(state);
state.misc.diff_content = diff_output;
state.misc.diff_scroll = 0;
state.misc.overlay = crate::state::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();
}
}
}
/// 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 colored +/- markers.
fn git_diff_output(state: &crate::state::AppStateRest) -> String {
let root = state
.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
}
+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();
}
}
}
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
//! Standalone helper functions that operate on AppStateRest.
//!
//! These are kept separate from the AppStateRest impl block to keep that
//! file focused on state definition and direct mutations. Helpers that
//! aggregate, compute, or read from the filesystem live here.
use crate::state::AppStateRest;
// ---------------------------------------------------------------------------
// Effort levels
// ---------------------------------------------------------------------------
/// Name of each reasoning-effort tier.
pub const EFFORT_LEVELS: &[&str] = &[
"Auto \u{2014} let the provider decide",
"Low \u{2014} fast, minimal reasoning",
"Medium \u{2014} balanced speed & reasoning",
"High \u{2014} thorough reasoning",
"Maximum \u{2014} deep analysis",
];
/// Return the current effort index from state.
pub fn current_effort(state: &AppStateRest) -> usize {
state
.misc
.effort_level
.saturating_sub(1)
.min(EFFORT_LEVELS.len().saturating_sub(1))
}
/// Cycle effort level up or down.
pub fn cycle_effort(state: &mut AppStateRest, forward: bool) {
let n = EFFORT_LEVELS.len();
if forward {
state.misc.effort_level = (state.misc.effort_level % n) + 1;
} else {
state.misc.effort_level = if state.misc.effort_level <= 1 {
n
} else {
state.misc.effort_level - 1
};
}
state.mark_dirty();
}
// ---------------------------------------------------------------------------
// Learning items
// ---------------------------------------------------------------------------
/// A lesson entry displayed in the Learning overlay.
#[derive(Debug, Clone)]
pub enum LearningItem {
/// A newly-generated lesson pending user approval.
Pending {
name: String,
content: String,
scope: String,
confidence: f64,
},
/// A lesson that has been accepted and stored.
Stored {
name: String,
content: String,
lifecycle: String,
scope: String,
description: String,
},
}
/// Read lesson markdown files from the `lessons/` subdirectory under the
/// memory directory.
#[tracing::instrument(skip(state))]
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
let lessons_dir = state.memory_dir.join("lessons");
if !lessons_dir.exists() {
return Vec::new();
}
let mut items = Vec::new();
if let Ok(entries) = std::fs::read_dir(&lessons_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
if let Ok(content) = std::fs::read_to_string(&path) {
items.push(LearningItem::Stored {
name: path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
content,
lifecycle: "filesystem".to_string(),
scope: "filesystem".to_string(),
description: String::new(),
});
}
}
}
}
items
}
// ---------------------------------------------------------------------------
// Generic helpers
// ---------------------------------------------------------------------------
/// Cycle the selected index within bounds.
pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize {
if n == 0 {
return 0;
}
if forward {
(current + 1) % n
} else if current == 0 {
n - 1
} else {
current - 1
}
}
/// Return the number of rewind points available.
pub fn rewind_count(state: &AppStateRest) -> usize {
state.transcript_cache.messages.len()
}
// ---------------------------------------------------------------------------
// Token counting
// ---------------------------------------------------------------------------
/// Resolve the window size for context window management.
///
/// Uses the model name from settings to determine max context window,
/// falling back to settings-configured max or 128k default.
#[tracing::instrument(skip(_app_config, settings))]
pub fn resolve_context_window(
_app_config: &zesdex_domain::cms::AppConfig,
settings: &zesdex_domain::cms::Settings,
) -> usize {
if let Some(max) = settings.max_tokens {
if max > 0 {
return max as usize;
}
}
256_000
}
/// Count tokens using tiktoken, fall back to character estimation.
///
/// Flow: try tiktoken-rs `cl100k_base` BPE encoding \u{2192} return accurate count.
/// On failure (~4 chars per token heuristic), fall back to character-based
/// estimation so the UI never blocks on an unavailable tokeniser.
#[tracing::instrument]
pub fn count_tokens(text: &str) -> usize {
if let Ok(bpe) = tiktoken_rs::cl100k_base() {
return bpe.encode_with_special_tokens(text).len();
}
// Fallback: ~4 chars per token
text.len().div_ceil(4)
}
+270
View File
@@ -0,0 +1,270 @@
//! Chat input buffer, cursor, history, and autocomplete state.
use std::path::PathBuf;
/// Which source populated the autocomplete dropdown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteKind {
/// Builtin slash-command (e.g. `/model`, `/help`).
Command,
/// `@file` mention from the workspace file index.
FileMention,
}
/// Builtin slash-commands recognised by the chat input autocomplete.
const COMMANDS: &[&str] = &[
"/help", "/quit", "/clear", "/login", "/login zen", "/login openai",
"/edit", "/mcp add", "/model", "/model ls", "/model add",
"/todo", "/usage", "/compact",
];
/// The user's input buffer, cursor position, history, and autocomplete
/// state for the chat prompt.
#[derive(Debug, Clone)]
pub struct InputState {
/// Raw UTF-8 input buffer content.
pub buffer: String,
/// Byte offset of the cursor within `buffer`.
pub cursor: usize,
/// Previously submitted input lines, oldest-first.
pub history: Vec<String>,
/// Index into `history` when browsing (None = at the current input).
pub history_idx: Option<usize>,
/// Current autocomplete candidate list.
pub autocomplete_candidates: Vec<String>,
/// Focused index within `autocomplete_candidates`.
pub autocomplete_idx: usize,
/// Whether the autocomplete dropdown is visible.
pub autocomplete_visible: bool,
/// Which kind of autocomplete is active.
pub autocomplete_kind: AutocompleteKind,
/// Byte offset of the `@` character that triggered file mention autocomplete.
pub mention_start: usize,
/// Optional path to a persistent history file.
pub history_file: Option<PathBuf>,
}
impl InputState {
/// Create an empty input state.
pub fn new() -> Self {
InputState {
buffer: String::new(),
cursor: 0,
history: Vec::new(),
history_idx: None,
autocomplete_candidates: Vec::new(),
autocomplete_idx: 0,
autocomplete_visible: false,
autocomplete_kind: AutocompleteKind::Command,
mention_start: 0,
history_file: None,
}
}
/// Hide the autocomplete dropdown and clear its state.
pub fn close_autocomplete(&mut self) {
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.autocomplete_idx = 0;
self.autocomplete_kind = AutocompleteKind::Command;
self.mention_start = 0;
}
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`.
pub fn open_autocomplete(&mut self) {
let trimmed = self.buffer.trim().to_string();
if trimmed.is_empty() || !trimmed.starts_with('/') {
self.close_autocomplete();
return;
}
let prefix = trimmed.to_lowercase();
self.autocomplete_candidates = COMMANDS
.iter()
.filter(|c| c.starts_with(&prefix))
.map(std::string::ToString::to_string)
.collect();
self.autocomplete_kind = AutocompleteKind::Command;
self.autocomplete_idx = 0;
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
}
/// Find the `@mention` token (if any) immediately before the cursor.
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
let before_cursor = &self.buffer[..self.cursor];
let at_pos = before_cursor.rfind('@')?;
let between = &before_cursor[at_pos + 1..];
if between.chars().any(char::is_whitespace) {
return None;
}
let boundary_ok = at_pos == 0
|| before_cursor[..at_pos]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
if !boundary_ok {
return None;
}
Some((at_pos, between.to_string()))
}
/// Open or refresh the `@file` mention dropdown from `files`.
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
let Some((start, query)) = self.mention_query_at_cursor() else {
self.close_autocomplete();
return;
};
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
let matched_files = pattern.match_list(files.iter(), &mut matcher);
self.autocomplete_candidates = matched_files
.into_iter()
.take(10)
.map(|(f, _)| f.clone())
.collect();
self.autocomplete_kind = AutocompleteKind::FileMention;
self.mention_start = start;
self.autocomplete_idx = 0;
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
}
/// Move the autocomplete selection up (forward=false) or down (forward=true).
pub fn cycle_autocomplete(&mut self, forward: bool) {
let n = self.autocomplete_candidates.len();
if n == 0 {
return;
}
if forward {
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
} else {
self.autocomplete_idx = if self.autocomplete_idx == 0 {
n - 1
} else {
self.autocomplete_idx - 1
};
}
}
/// Accept the currently selected autocomplete candidate.
pub fn select_autocomplete(&mut self) -> bool {
let Some(candidate) = self
.autocomplete_candidates
.get(self.autocomplete_idx)
.cloned()
else {
return false;
};
match self.autocomplete_kind {
AutocompleteKind::Command => {
self.buffer = candidate;
self.cursor = self.buffer.len();
}
AutocompleteKind::FileMention => {
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
self.close_autocomplete();
return false;
}
let replacement = format!("@{candidate} ");
self.buffer
.replace_range(self.mention_start..self.cursor, &replacement);
self.cursor = self.mention_start + replacement.len();
}
}
self.close_autocomplete();
true
}
/// Tab-complete: open dropdown or cycle forward.
pub fn tab_complete(&mut self) {
if self.autocomplete_visible {
self.cycle_autocomplete(true);
} else {
self.open_autocomplete();
}
}
/// Insert a character at the cursor position.
pub fn insert(&mut self, c: char) {
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
/// Delete the character to the left of the cursor (backspace).
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.buffer.remove(self.cursor);
}
}
/// Delete the character at the cursor position (forward delete).
pub fn delete_right(&mut self) {
if self.cursor < self.buffer.len() {
self.buffer.remove(self.cursor);
}
}
/// Submit the current buffer and return the submitted text.
pub fn submit(&mut self) -> String {
let result = self.buffer.clone();
if !result.is_empty() {
if self.history.last() != Some(&result) {
self.history.push(result.clone());
if let Some(ref path) = self.history_file {
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
use std::io::Write;
let _ = writeln!(file, "{result}");
}
}
}
self.history_idx = None;
}
self.buffer.clear();
self.cursor = 0;
result
}
/// Navigate backward through input history.
pub fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
let idx = match self.history_idx {
Some(i) if i > 0 => i - 1,
None => self.history.len() - 1,
Some(_) => return,
};
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
}
/// Navigate forward through input history.
pub fn history_down(&mut self) {
match self.history_idx {
Some(i) if i < self.history.len() - 1 => {
let idx = i + 1;
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
}
Some(_) => {
self.history_idx = None;
self.buffer.clear();
self.cursor = 0;
}
None => {}
}
}
}
impl Default for InputState {
fn default() -> Self {
Self::new()
}
}
+195
View File
@@ -0,0 +1,195 @@
//! Miscellaneous state: overlay enum, misc state bag, editor state.
use std::path::PathBuf;
use zesdex_infrastructure::Toast;
/// Which modal overlay, if any, is currently shown over the main TUI view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
/// No overlay; the main chat view is shown.
None,
/// Key bindings help screen.
Help,
/// Settings/configuration panel.
Settings,
/// Background bash job viewer.
Bash,
/// "Are you sure you want to quit?" confirmation.
QuitConfirm,
/// Raw key-code input capture (for binding custom keys).
KeyInput,
/// Inline editor (opened via `/edit`).
Editor,
/// Reasoning effort level selector.
Effort,
/// MCP server management panel.
Mcp,
/// Task list overlay.
Todo,
/// Project plan overlay.
Plan,
/// Session rewind / history scrubber.
Rewind,
/// Learning / lesson management panel.
Learning,
/// Token usage statistics panel.
Usage,
/// Generic loading spinner overlay.
Loading,
/// Model selector dropdown.
ModelSelector,
/// "Clear conversation?" confirmation.
ClearConfirm,
/// Git diff preview overlay.
Diff,
}
impl Overlay {
/// Human-readable name for this overlay variant.
pub fn as_str(&self) -> &'static str {
match self {
Overlay::None => "none",
Overlay::Help => "help",
Overlay::Settings => "settings",
Overlay::Bash => "bash",
Overlay::QuitConfirm => "quit_confirm",
Overlay::KeyInput => "key_input",
Overlay::Editor => "editor",
Overlay::Effort => "effort",
Overlay::Mcp => "mcp",
Overlay::Todo => "todo",
Overlay::Plan => "plan",
Overlay::Rewind => "rewind",
Overlay::Learning => "learning",
Overlay::Usage => "usage",
Overlay::Loading => "loading",
Overlay::ModelSelector => "model_selector",
Overlay::ClearConfirm => "clear_confirm",
Overlay::Diff => "diff",
}
}
/// Whether any overlay (i.e. anything other than `None`) is active.
pub fn is_active(self) -> bool {
!matches!(self, Overlay::None)
}
}
impl std::fmt::Display for Overlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Simple inline editor state for the TUI.
#[derive(Debug, Clone)]
pub struct EditorState {
/// Path to the file being edited.
pub path: PathBuf,
/// Current buffer content.
pub content: String,
/// Cursor position (byte offset).
pub cursor: usize,
}
impl EditorState {
/// Create a new editor state for the given path.
pub fn new(path: PathBuf, content: String) -> Self {
let cursor = content.len();
EditorState { path, content, cursor }
}
/// Return the full buffer content.
pub fn as_string(&self) -> String {
self.content.clone()
}
/// Delete one character to the left of the cursor.
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.content.remove(self.cursor);
}
}
}
/// The "miscellaneous" slice of app state.
#[derive(Debug, Clone)]
pub struct MiscState {
/// Currently active modal overlay (None = main chat view).
pub overlay: Overlay,
/// Active toast notifications.
pub toasts: Vec<Toast>,
/// Timestamp (ms) of the last staleness sweep for lesson cache.
pub last_staleness_sweep_ms: i64,
/// Whether the agent is currently "thinking".
pub thinking: bool,
/// Current LLM reasoning effort level (1-5).
pub effort_level: usize,
/// Currently focused index in list-type overlays.
pub selected_index: usize,
/// Optional inline editor state.
pub editor: Option<EditorState>,
/// Whether the API connection is established.
pub api_connected: bool,
/// Monotonically increasing tick count, incremented each render frame.
pub tick_count: u64,
/// Cached content of the TODO file.
pub todo_content: String,
/// Cached content of the PLAN file.
pub plan_content: String,
/// Whether a lesson background task is currently running.
pub lesson_running: bool,
/// Text waiting to be written to the system clipboard.
pub pending_clipboard_copy: Option<String>,
/// Cached git diff content for the preview overlay.
pub diff_content: String,
/// Scroll offset for the diff overlay.
pub diff_scroll: usize,
}
impl MiscState {
/// Create a fresh `MiscState` with no overlay, no toasts.
pub fn new() -> Self {
MiscState {
overlay: Overlay::None,
toasts: Vec::new(),
last_staleness_sweep_ms: 0,
thinking: false,
effort_level: 1,
selected_index: 0,
editor: None,
api_connected: false,
tick_count: 0,
todo_content: String::new(),
plan_content: String::new(),
lesson_running: false,
pending_clipboard_copy: None,
diff_content: String::new(),
diff_scroll: 0,
}
}
/// Append a toast notification to the active list.
pub fn push_toast(&mut self, toast: Toast) {
self.toasts.push(toast);
}
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> {
let expired: Vec<_> = self
.toasts
.iter()
.filter(|t| t.expired(now_ms))
.cloned()
.collect();
self.toasts.retain(|t| !t.expired(now_ms));
expired
}
}
impl Default for MiscState {
fn default() -> Self {
Self::new()
}
}
+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,
)
}
}
+36
View File
@@ -0,0 +1,36 @@
//! Viewport scroll state: current offset and visible-line count.
/// Viewport scroll state: current offset and visible-line count.
#[derive(Debug, Clone)]
pub struct ScrollState {
/// Current scroll offset (how many lines have been scrolled past).
pub offset: usize,
/// Maximum number of lines that fit in the visible viewport area.
pub max_visible: usize,
}
impl ScrollState {
/// Create a `ScrollState` with zero offset and 30 rows visible.
pub fn new() -> Self {
ScrollState {
offset: 0,
max_visible: 30,
}
}
/// Scroll the viewport up by `amount` lines (increasing the offset).
pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_add(amount);
}
/// Scroll the viewport down by `amount` lines (decreasing the offset).
pub fn scroll_down(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount);
}
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,76 @@
//! Transcript display types: bounded ring-buffer cache of rendered messages.
use std::collections::VecDeque;
use zesdex_domain::core::Role;
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
/// Message author: User or Assistant.
pub role: Role,
/// Rendered text content (plain text, no markdown).
pub content: String,
/// Model reasoning (e.g. from DeepSeek-R1 <think> block).
pub reasoning: String,
/// Millisecond timestamp when this display entry was created.
pub timestamp: i64,
}
impl ChatMessageDisplay {
/// Build a display entry, stamping it with the current time.
pub fn new(role: Role, content: String) -> Self {
ChatMessageDisplay {
role,
content,
reasoning: String::new(),
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
}
/// Bounded ring of recent chat messages used to render the transcript view.
#[derive(Debug, Clone)]
pub struct TranscriptCache {
/// Ordered display messages (newest appended, oldest evicted when full).
/// Uses VecDeque for O(1) front eviction instead of O(n) Vec::remove(0).
pub messages: VecDeque<ChatMessageDisplay>,
/// Maximum messages to retain before evicting the oldest.
pub max_lines: usize,
/// Whether the cache has changed since the last render sweep.
pub dirty: bool,
}
impl TranscriptCache {
/// Create an empty transcript cache holding at most `max_lines` messages.
pub fn new(max_lines: usize) -> Self {
TranscriptCache {
messages: VecDeque::new(),
max_lines,
dirty: true,
}
}
/// Append a message, evicting the oldest if at capacity.
/// Eviction is O(1) via VecDeque::pop_front instead of O(n) Vec::remove(0).
pub fn push(&mut self, msg: ChatMessageDisplay) {
self.messages.push_back(msg);
while self.messages.len() > self.max_lines {
self.messages.pop_front();
}
self.dirty = true;
}
/// Append text to the last assistant message, if one exists.
pub fn append_to_last(&mut self, text: &str, is_reasoning: bool) {
if let Some(msg) = self.messages.back_mut() {
if msg.role == Role::Assistant {
if is_reasoning {
msg.reasoning.push_str(text);
} else {
msg.content.push_str(text);
}
self.dirty = true;
}
}
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Simplified agent lifecycle display types for the workflow sidebar.
/// Simplified agent lifecycle state for TUI display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentState {
Idle,
Running,
Completed,
Failed,
}
/// A single agent entry in the workflow sidebar.
#[derive(Debug, Clone)]
pub struct SimpleAgent {
/// Agent unique ID (e.g. "auto-review", "Node-0-1").
pub name: String,
/// Human-readable display label (e.g. "Auto-Review", "Backend API Agent").
pub display_name: String,
/// Current lifecycle state.
pub state: AgentState,
/// Millisecond timestamp when the agent started.
pub started_at: Option<i64>,
/// Millisecond timestamp when the agent completed.
pub completed_at: Option<i64>,
/// Optional error message if the agent failed.
pub error: Option<String>,
/// Optional progress text (current tool, step description).
pub progress: Option<String>,
}
impl SimpleAgent {
/// Create a new agent with the given name (used as both ID and display name).
pub fn new(name: String) -> Self {
SimpleAgent {
display_name: name.clone(),
name,
state: AgentState::Idle,
started_at: None,
completed_at: None,
error: None,
progress: None,
}
}
/// Create a new agent with separate ID and display label.
pub fn with_display(name: String, display_name: String) -> Self {
SimpleAgent {
name,
display_name,
state: AgentState::Idle,
started_at: None,
completed_at: None,
error: None,
progress: None,
}
}
}
/// Simplified workflow engine state for TUI display.
#[derive(Debug, Clone)]
pub struct SimpleWorkflowEngine {
/// Active agents in the workflow.
pub agents: Vec<SimpleAgent>,
/// Summary findings produced by completed agents.
pub findings: Vec<String>,
}
impl SimpleWorkflowEngine {
/// Create an empty workflow engine state.
pub fn new() -> Self {
SimpleWorkflowEngine {
agents: Vec::new(),
findings: Vec::new(),
}
}
}
impl Default for SimpleWorkflowEngine {
fn default() -> Self {
Self::new()
}
}
+69 -42
View File
@@ -1,22 +1,74 @@
//! TUI agent turn interface adapter — delegates execution to `zesdex_infrastructure::agent`.
//! TUI agent turn interface adapter — resolves LLM provider configuration,
//! builds the tool context, and spawns the agent turn on a background task.
use std::sync::atomic::Ordering;
use tracing::info;
use zesdex_domain::core::ChatMessage;
use zesdex_domain::agent::AgentTurnParams;
use zesdex_infrastructure::llm::provider::LlmClient;
use zesdex_infrastructure::tools::executor::InfrastructureToolExecutor;
use zesdex_infrastructure::tools::{all_tools, tool_defs, ToolCtx};
use zesdex_application::agent::turn_service::AgentTurnServiceImpl;
use zesdex_application::agent::AgentTurnService;
use crate::state::AppStateRest;
/// Spawn an agent turn on a background thread by delegating to `zesdex-infrastructure`.
// ---------------------------------------------------------------------------
// Provider resolution
// ---------------------------------------------------------------------------
/// Resolve the API key from settings or environment for the given provider.
fn resolve_api_key(state: &AppStateRest, provider_name: &str) -> String {
if let Some(key) = state.settings.api_keys.get(provider_name) {
return key.clone();
}
if let Some(ref cfg) = state.app_config.providers.get(provider_name) {
if let Some(ref default_key) = cfg.default_api_key {
if !default_key.is_empty() {
return default_key.clone();
}
}
if let Some(ref env_name) = cfg.api_key_env {
if let Ok(val) = std::env::var(env_name) {
return val;
}
}
}
String::new()
}
/// Resolve the API base URL from the provider config.
fn resolve_api_base(state: &AppStateRest, provider_name: &str) -> Option<String> {
state
.app_config
.providers
.get(provider_name)
.map(|cfg| cfg.api_base.clone())
}
// ---------------------------------------------------------------------------
// Turn spawning
// ---------------------------------------------------------------------------
/// Spawn an agent turn on a background Tokio task.
///
/// Flow:
/// 1. Compare-exchange the in-flight flag (no-op if already running).
/// 2. Resolve provider (API key, model, base URL) from settings.
/// 3. Build messages including the user's input text.
/// 4. Construct `AgentTurnParams` with the turn-event queue and abort flag.
/// 5. Create `LlmClient`, `ToolCtx`, and `InfrastructureToolExecutor`.
/// 6. Assemble `AgentTurnServiceImpl` and spawn it via `tokio::spawn`.
#[tracing::instrument(skip(state))]
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
// compare_exchange: only mark in-flight if not already running
// Only one turn at a time — compare_exchange is lock-free
if state
.turn_in_flight_flag
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_err()
{
return; // already running
return;
}
let turn_events = state.turn_events.clone();
@@ -25,29 +77,13 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
let session_dir = state.session_dir.clone();
let workspace_roots = state.workspace_roots.clone();
// Resolve LLM provider configuration from settings
// ── Resolve provider configuration ─────────────────────────────────
let provider_name = &state.settings.provider;
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
let mut api_key = String::new();
if let Some(key) = state.settings.api_keys.get(provider_name) {
api_key = key.clone();
} else if let Some(ref cfg) = provider_cfg {
if let Some(ref default_key) = cfg.default_api_key {
api_key = default_key.clone();
}
if api_key.is_empty() {
if let Some(ref env_name) = cfg.api_key_env {
if let Ok(val) = std::env::var(env_name) {
api_key = val;
}
}
}
}
let api_key = resolve_api_key(state, provider_name);
let model = state.settings.model.clone();
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
let api_base = resolve_api_base(state, provider_name);
// ── Build message list ─────────────────────────────────────────────
let mut messages: Vec<ChatMessage> = state
.session_runtime
.as_ref()
@@ -59,8 +95,9 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
rt.messages = messages.clone();
}
info!("delegating agent turn to infrastructure engine (model: {})", model);
info!("Spawning agent turn (model: {model})");
// ── Assemble dependencies (composition root) ──────────────────────
let params = AgentTurnParams {
messages,
session_dir: session_dir.clone(),
@@ -73,32 +110,22 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
api_base: api_base.clone(),
};
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
api_key,
model,
api_base,
));
let client = std::sync::Arc::new(LlmClient::new(api_key, model, api_base));
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
let tool_ctx = ToolCtx::builder()
.session_dir(session_dir)
.workspaces(workspace_roots)
.turn_events(turn_events)
.build();
let tool_executor = std::sync::Arc::new(
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
);
let tool_executor =
std::sync::Arc::new(InfrastructureToolExecutor::new(tool_ctx));
let tools = zesdex_infrastructure::tools::all_tools();
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
let tools = all_tools();
let defs = tool_defs(&tools);
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
client,
tool_executor,
defs,
);
let turn_service = AgentTurnServiceImpl::new(client, tool_executor, defs);
use zesdex_application::agent::AgentTurnService;
tokio::spawn(async move {
let _ = turn_service.run_turn(params).await;
});
@@ -31,7 +31,7 @@ pub fn render(
let summary = runtime.map(|r| compute_usage_summary(&r.usage, r.session_start, now_ms));
let (edit_count, lesson_count, review_count, consec_empty) =
runtime.map_or((0, 0, 0, 0), |r| {
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
(r.edit_count, r.lessons.total, r.review_count, r.consecutive_empty_reviews)
});
let mut lines = vec![
Line::from(Span::styled(