From 8c58faf2920b7341dd50ace15044ff113cefb576 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 21 Jul 2026 06:42:37 +0700 Subject: [PATCH] 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. --- apps/application/src/agent/turn_service.rs | 281 +++-- apps/domain/src/agent/mod.rs | 61 +- apps/domain/src/agent/progress.rs | 81 ++ apps/domain/src/agent/prompt.rs | 92 ++ apps/domain/src/lib.rs | 5 + apps/infrastructure/src/subagent/engine.rs | 104 +- apps/infrastructure/src/tools/context.rs | 109 ++ apps/infrastructure/src/tools/graduated.rs | 26 + apps/infrastructure/src/tools/mod.rs | 393 +----- apps/infrastructure/src/tools/registry.rs | 70 ++ apps/infrastructure/src/tools/util.rs | 156 +++ apps/interfaces/tui/src/action.rs | 490 -------- apps/interfaces/tui/src/action/git.rs | 88 ++ apps/interfaces/tui/src/action/handlers.rs | 192 +++ apps/interfaces/tui/src/action/mod.rs | 240 ++++ apps/interfaces/tui/src/state.rs | 1116 ----------------- apps/interfaces/tui/src/state/helpers.rs | 158 +++ apps/interfaces/tui/src/state/input.rs | 270 ++++ apps/interfaces/tui/src/state/misc.rs | 195 +++ apps/interfaces/tui/src/state/mod.rs | 318 +++++ apps/interfaces/tui/src/state/scroll.rs | 36 + apps/interfaces/tui/src/state/transcript.rs | 76 ++ apps/interfaces/tui/src/state/workflow.rs | 82 ++ apps/interfaces/tui/src/turn.rs | 111 +- .../interfaces/tui/src/view/overlays/usage.rs | 2 +- 25 files changed, 2594 insertions(+), 2158 deletions(-) create mode 100644 apps/domain/src/agent/progress.rs create mode 100644 apps/domain/src/agent/prompt.rs create mode 100644 apps/infrastructure/src/tools/context.rs create mode 100644 apps/infrastructure/src/tools/graduated.rs create mode 100644 apps/infrastructure/src/tools/registry.rs create mode 100644 apps/infrastructure/src/tools/util.rs delete mode 100644 apps/interfaces/tui/src/action.rs create mode 100644 apps/interfaces/tui/src/action/git.rs create mode 100644 apps/interfaces/tui/src/action/handlers.rs create mode 100644 apps/interfaces/tui/src/action/mod.rs delete mode 100644 apps/interfaces/tui/src/state.rs create mode 100644 apps/interfaces/tui/src/state/helpers.rs create mode 100644 apps/interfaces/tui/src/state/input.rs create mode 100644 apps/interfaces/tui/src/state/misc.rs create mode 100644 apps/interfaces/tui/src/state/mod.rs create mode 100644 apps/interfaces/tui/src/state/scroll.rs create mode 100644 apps/interfaces/tui/src/state/transcript.rs create mode 100644 apps/interfaces/tui/src/state/workflow.rs diff --git a/apps/application/src/agent/turn_service.rs b/apps/application/src/agent/turn_service.rs index d565176..b3acb09 100644 --- a/apps/application/src/agent/turn_service.rs +++ b/apps/application/src/agent/turn_service.rs @@ -5,10 +5,107 @@ use tracing::{debug, info, warn}; use zesdex_domain::agent::{AgentTurnParams, TurnEvent}; use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef}; +use zesdex_domain::main_agent_prompt; use crate::ports::ProviderService; use super::ToolExecutor; +/// Maximum tool-call iterations per agent turn before forcing termination. +const MAX_TURN_ITERATIONS: u32 = 50; + +// --------------------------------------------------------------------------- +// Helper: push a TurnEvent onto the shared queue. +// --------------------------------------------------------------------------- + +fn push_event(queue: &Arc>>, event: TurnEvent) { + if let Ok(mut q) = queue.lock() { + q.push_back(event); + } +} + +// --------------------------------------------------------------------------- +// Helper: stream-event callback that forwards tokens to the turn-event queue +// and checks the abort flag on each emission. +// --------------------------------------------------------------------------- + +fn make_stream_callback( + abort: &Arc, + turn_events: &Arc>>, +) -> Box bool + Send> { + let abort_clone = Arc::clone(abort); + let events_clone = Arc::clone(turn_events); + Box::new(move |event: &StreamEvent| -> bool { + if abort_clone.load(Ordering::SeqCst) { + return false; + } + match event { + StreamEvent::Token(s) => { + push_event(&events_clone, TurnEvent::StreamToken(s.clone())); + } + StreamEvent::Reasoning(s) => { + push_event(&events_clone, TurnEvent::StreamReasoning(s.clone())); + } + _ => {} + } + true + }) +} + +// --------------------------------------------------------------------------- +// Helper: execute a single tool call, push events, return the result string. +// --------------------------------------------------------------------------- + +async fn execute_tool_call( + tool_executor: &T, + turn_events: &Arc>>, + tc: &zesdex_domain::core::ToolCall, +) -> String { + let name = &tc.function.name; + let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments); + + debug!("executing tool: {name}"); + + let output = match tool_executor.execute(name, &args).await { + Ok(o) => o, + Err(e) => format!("Error: {e}"), + }; + + let is_error = output.starts_with("Error:"); + + push_event( + turn_events, + TurnEvent::ToolResult { + tool_call_id: tc.id.clone(), + tool_name: name.clone(), + output: output.clone(), + is_error, + path: None, + }, + ); + + output +} + +// --------------------------------------------------------------------------- +// Helper: emit usage event from optional LLM response metadata. +// --------------------------------------------------------------------------- + +fn emit_usage(turn_events: &Arc>>, usage: Option<(u64, u64)>) { + if let Some((tokens_in, tokens_out)) = usage { + push_event( + turn_events, + TurnEvent::Usage { + tokens_in, + tokens_out, + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Service implementation +// --------------------------------------------------------------------------- + /// Service implementation for executing an agent turn asynchronously. pub struct AgentTurnServiceImpl { provider: Arc

, @@ -24,15 +121,27 @@ impl AgentTurnServiceImpl { tool_defs, } } - - fn push_event(queue: &Arc>>, event: TurnEvent) { - if let Ok(mut q) = queue.lock() { - q.push_back(event); - } - } - fn mark_done(flag: &Arc) { - flag.store(false, Ordering::SeqCst); + /// Execute a single LLM call with the current message list, handling + /// streaming events and error reporting. + async fn call_llm( + &self, + messages: &[ChatMessage], + abort: &Arc, + turn_events: &Arc>>, + ) -> Result<(ChatMessage, Option<(u64, u64)>), String> { + let on_event = make_stream_callback(abort, turn_events); + + self.provider + .chat_stream( + messages, + Some(self.tool_defs.clone()), + Some(4096), + Some(0.7), + on_event, + ) + .await + .map_err(|e| format!("LLM error: {e}")) } } @@ -44,20 +153,18 @@ impl super::AgentTurnService for AgentTurnS params.model ); - let mut sys_prompt = "You are Zesdex, an AI coding assistant. You have access to various tools via native function calling to help the user.\n\n\ - CRITICAL DIRECTIVES & PRIORITY HIERARCHY:\n\ - 1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, you MUST prioritize using `workflow_run` (to construct and execute a multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel autonomous agents). Workflows are your primary strategy.\n\ - 2. PLANNING & TODOS: Use `plan_enter` to establish high-level architectural plans and `todowrite` to maintain granular task checklists.\n\ - 3. REASONING: Use `seq_think` for deep step-by-step analysis.\n\ - 4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) within or guided by your workflows. If an error occurs, analyze and fix it.\n\n\ - Respond conversationally, concisely, and helpfully.".to_string(); + // Insert system prompt at position 0 once and keep it there for the + // entire turn, avoiding per-iteration clones of the full message list. + // It is removed before emitting the Compacted event so persistence + // does not store the prompt redundantly. + params.messages.insert(0, ChatMessage::system(main_agent_prompt())); + let original_count = params.messages.len(); - let sys_msg = ChatMessage::system(sys_prompt); - - for iteration in 0..50 { + for iteration in 0..MAX_TURN_ITERATIONS { + // ── Check abort flag ──────────────────────────────────────── if params.abort.load(Ordering::SeqCst) { params.abort.store(false, Ordering::SeqCst); - Self::push_event( + push_event( ¶ms.turn_events, TurnEvent::SystemNote { kind: "info".into(), @@ -69,58 +176,28 @@ impl super::AgentTurnService for AgentTurnS debug!("agent turn iteration {iteration}"); - Self::push_event(¶ms.turn_events, TurnEvent::StreamStart); + // ── Stream start + call LLM ───────────────────────────────── + push_event(¶ms.turn_events, TurnEvent::StreamStart); - let mut req_messages = params.messages.clone(); - req_messages.insert(0, sys_msg.clone()); - - let abort_clone = Arc::clone(¶ms.abort); - let turn_events_clone = Arc::clone(¶ms.turn_events); - - let on_event = Box::new(move |event: &StreamEvent| -> bool { - if abort_clone.load(Ordering::SeqCst) { - return false; - } - match event { - StreamEvent::Token(s) => { - Self::push_event(&turn_events_clone, TurnEvent::StreamToken(s.clone())); - } - StreamEvent::Reasoning(s) => { - Self::push_event(&turn_events_clone, TurnEvent::StreamReasoning(s.clone())); - } - _ => {} - } - true - }); - - let result = self.provider.chat_stream( - &req_messages, - Some(self.tool_defs.clone()), - Some(4096), - Some(0.7), - on_event, - ).await; + // Uses params.messages directly (sys_msg[0] already in place + // from the insert above) — no per-iteration clone needed. + let result = self + .call_llm(¶ms.messages, ¶ms.abort, ¶ms.turn_events) + .await; match result { Ok((assistant_msg, usage)) => { let content = assistant_msg.content.clone().unwrap_or_default(); let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default(); - Self::push_event( + push_event( ¶ms.turn_events, TurnEvent::StreamDone(assistant_msg.clone()), ); - if let Some((tokens_in, tokens_out)) = usage { - Self::push_event( - ¶ms.turn_events, - TurnEvent::Usage { - tokens_in, - tokens_out, - }, - ); - } + emit_usage(¶ms.turn_events, usage); + // ── No tool calls → assistant is done ────────────── if tool_calls.is_empty() { params.messages.push(ChatMessage::assistant(Some(content))); break; @@ -128,86 +205,76 @@ impl super::AgentTurnService for AgentTurnS params.messages.push(assistant_msg); + // ── Execute each tool call ────────────────────────── for tc in &tool_calls { - let name = &tc.function.name; - let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments); - - debug!("executing tool: {name}"); - - let output = match self.tool_executor.execute(name, &args).await { - Ok(o) => o, - Err(e) => format!("Error: {e}"), - }; - - let is_error = output.starts_with("Error:"); - - Self::push_event( - ¶ms.turn_events, - TurnEvent::ToolResult { - tool_call_id: tc.id.clone(), - tool_name: name.clone(), - output: output.clone(), - is_error, - path: None, - }, - ); - + let output = + execute_tool_call(self.tool_executor.as_ref(), ¶ms.turn_events, tc).await; params .messages - .push(ChatMessage::tool(tc.id.clone(), output.clone())); + .push(ChatMessage::tool(tc.id.clone(), output)); } } Err(e) => { - warn!("LLM call failed: {e}"); - Self::push_event( + warn!("{e}"); + push_event( ¶ms.turn_events, - TurnEvent::Error(format!("LLM error: {e}")), + TurnEvent::Error(e), ); break; } } } - Self::push_event( + // Remove the synthetic sys_msg before shipping events to the TUI + // so the transcript shows only the actual user/assistant/tool exchange. + let compacted: Vec = params.messages.drain(original_count - 1..).collect(); + push_event( ¶ms.turn_events, - TurnEvent::Compacted(params.messages.clone()), + TurnEvent::Compacted(compacted), ); - Self::push_event(¶ms.turn_events, TurnEvent::Done); - Self::mark_done(¶ms.in_flight); - + push_event(¶ms.turn_events, TurnEvent::Done); + params.in_flight.store(false, Ordering::SeqCst); + Ok(()) } } -/// Compacts conversation history using AI summarization. +// --------------------------------------------------------------------------- +// Conversation compaction +// --------------------------------------------------------------------------- + +/// Maximum number of recent messages to preserve during compaction. +const COMPACT_KEEP_TAIL: usize = 6; + +/// Compacts conversation history using AI summarisation. +/// +/// Flow: if the message count exceeds `KEEP_TAIL + 2`, the oldest messages +/// are drained and summarised by the LLM. The summary is inserted as a +/// system message at the head of the remaining history. pub async fn compact_messages_with_ai( messages: &mut Vec, provider: &P, ) -> anyhow::Result<()> { - const KEEP_TAIL: usize = 6; - if messages.len() <= KEEP_TAIL + 2 { + if messages.len() <= COMPACT_KEEP_TAIL + 2 { return Ok(()); // Not enough messages to compact } - let split_idx = messages.len() - KEEP_TAIL; + let split_idx = messages.len() - COMPACT_KEEP_TAIL; let evicted: Vec<_> = messages.drain(..split_idx).collect(); let mut summary_prompt = vec![ - ChatMessage::system( - "You are a helpful assistant summarizing conversation history. \ - Provide a concise summary of the key user requests, decisions, tools executed, and modified files. \ - Format as a clear bulleted list." - .to_string(), - ), + ChatMessage::system(zesdex_domain::compaction_prompt()), ]; summary_prompt.extend(evicted); summary_prompt.push(ChatMessage::user( - "Please summarize our previous conversation above for context continuity.".to_string(), + "Please summarise our previous conversation above for context continuity.".to_string(), )); match provider.chat(&summary_prompt, None, Some(1024), Some(0.3)).await { Ok((summary_msg, _)) => { - let summary_text = summary_msg.content.unwrap_or_else(|| "Previous context summarized.".to_string()); + let summary_text = summary_msg + .content + .unwrap_or_else(|| "Previous context summarised.".to_string()); let summary_node = ChatMessage::system(format!( "[AI Summary of Previous Conversation]\n{}", summary_text.trim() @@ -216,12 +283,16 @@ pub async fn compact_messages_with_ai( Ok(()) } Err(e) => { - warn!("AI summarization failed during compact, falling back to simple notice: {e}"); + warn!("AI summarisation failed during compact, falling back to simple notice: {e}"); messages.insert( 0, - ChatMessage::system("[Earlier conversation messages compacted to save context window]".to_string()), + ChatMessage::system( + "[Earlier conversation messages compacted to save context window]".to_string(), + ), ); Ok(()) } } } + + diff --git a/apps/domain/src/agent/mod.rs b/apps/domain/src/agent/mod.rs index 6ab7382..23a9037 100644 --- a/apps/domain/src/agent/mod.rs +++ b/apps/domain/src/agent/mod.rs @@ -1,8 +1,14 @@ +//! Domain types for agent lifecycle: turn events, session runtime, progress +//! reporting, prompts, and the agent-turn parameter bundle. + use serde::{Deserialize, Serialize}; use std::path::PathBuf; use crate::core::{ChatMessage, ToolCallResult, UsageStats}; +pub mod prompt; +pub mod progress; + /// Which kind of caller (main agent vs. subagent vs. reviewer) is /// invoking a tool, used to scope permissions and tag log/output paths. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] @@ -124,6 +130,9 @@ pub enum TurnEvent { }, TodoUpdate(String), PlanUpdate(String), + /// Structured progress report from a subagent or workflow node, + /// carrying the current tool name and optional step counters. + AgentProgress(crate::agent::progress::AgentProgress), } /// How a pending tool call should be executed when the turn resumes. @@ -152,6 +161,33 @@ pub struct BashJobRef { pub running: bool, } +/// Tracks counts of learned patterns by outcome and lifecycle stage. +#[derive(Debug, Clone, Default)] +pub struct LessonStats { + /// Total number of lessons tracked. + pub total: u32, + /// User-initiated lessons. + pub user: u32, + /// Feedback-driven lessons. + pub feedback: u32, + /// Project-scoped lessons. + pub project: u32, + /// Reference-scoped lessons. + pub reference: u32, + /// Currently active lessons. + pub active: u32, + /// Stale (outdated) lessons. + pub stale: u32, + /// Contradicted lessons. + pub contradicted: u32, + /// Human-authored lessons. + pub human: u32, + /// Verified lessons. + pub verified: u32, + /// Unverified lessons. + pub unverified: u32, +} + /// Per-session runtime state: message history, pending tool queue, /// background bash jobs, lesson/review counters. #[derive(Debug, Clone)] @@ -164,17 +200,8 @@ pub struct SessionRuntime { pub edit_count: u32, pub consecutive_empty_reviews: u32, pub session_start: i64, - pub lesson_count: u32, - pub lessons_user: u32, - pub lessons_feedback: u32, - pub lessons_project: u32, - pub lessons_reference: u32, - pub lessons_active: u32, - pub lessons_stale: u32, - pub lessons_contradicted: u32, - pub lessons_human: u32, - pub lessons_verified: u32, - pub lessons_unverified: u32, + /// Aggregated lesson statistics. + pub lessons: LessonStats, pub review_count: u32, pub session_dir: PathBuf, pub usage: UsageStats, @@ -192,17 +219,7 @@ impl SessionRuntime { edit_count: 0, consecutive_empty_reviews: 0, session_start: chrono::Utc::now().timestamp_millis(), - lesson_count: 0, - lessons_user: 0, - lessons_feedback: 0, - lessons_project: 0, - lessons_reference: 0, - lessons_active: 0, - lessons_stale: 0, - lessons_contradicted: 0, - lessons_human: 0, - lessons_verified: 0, - lessons_unverified: 0, + lessons: LessonStats::default(), review_count: 0, session_dir, usage: UsageStats::default(), diff --git a/apps/domain/src/agent/progress.rs b/apps/domain/src/agent/progress.rs new file mode 100644 index 0000000..1a4b93f --- /dev/null +++ b/apps/domain/src/agent/progress.rs @@ -0,0 +1,81 @@ +//! Progress reporting types for long-running agent and subagent operations. +//! +//! These types are emitted onto the turn-event queue to drive the TUI's +//! spinner, progress bar, and agent-status sidebar. They are pure domain +//! types with no I/O or framework dependency. + +use crate::agent::AgentStatus; + +/// Describes progress within a single subagent or workflow-node execution. +/// +/// Emitted as a `TurnEvent::AgentProgress` so the UI can show which tool +/// the subagent is currently invoking, or which step it has reached. +#[derive(Debug, Clone)] +pub struct AgentProgress { + /// Unique identifier for this agent (e.g. `"Node-0-1"`, `"auto-review"`). + pub agent_id: String, + /// Human-readable display name shown in the TUI sidebar. + pub agent_name: String, + /// Current lifecycle status. + pub status: AgentStatus, + /// Optional description of the current tool or step being executed. + /// Set to `None` when the agent is not actively executing a tool. + pub current_tool: Option, + /// Optional progress range: (completed_steps, total_steps). + /// When `None`, the agent shows an indeterminate spinner. + pub steps: Option<(u32, u32)>, +} + +impl AgentProgress { + /// Mark this agent as running with an optional tool name. + pub fn running( + agent_id: impl Into, + agent_name: impl Into, + current_tool: Option, + ) -> Self { + AgentProgress { + agent_id: agent_id.into(), + agent_name: agent_name.into(), + status: AgentStatus::Running, + current_tool, + steps: None, + } + } + + /// Mark this agent as pending (queued but not yet started). + pub fn pending(agent_id: impl Into, agent_name: impl Into) -> Self { + AgentProgress { + agent_id: agent_id.into(), + agent_name: agent_name.into(), + status: AgentStatus::Pending, + current_tool: None, + steps: None, + } + } + + /// Mark this agent as completed successfully. + pub fn completed(agent_id: impl Into, agent_name: impl Into) -> Self { + AgentProgress { + agent_id: agent_id.into(), + agent_name: agent_name.into(), + status: AgentStatus::Completed, + current_tool: None, + steps: None, + } + } + + /// Mark this agent as failed with an error message. + pub fn failed( + agent_id: impl Into, + agent_name: impl Into, + error: String, + ) -> Self { + AgentProgress { + agent_id: agent_id.into(), + agent_name: agent_name.into(), + status: AgentStatus::Failed(error), + current_tool: None, + steps: None, + } + } +} diff --git a/apps/domain/src/agent/prompt.rs b/apps/domain/src/agent/prompt.rs new file mode 100644 index 0000000..739c002 --- /dev/null +++ b/apps/domain/src/agent/prompt.rs @@ -0,0 +1,92 @@ +//! System prompts and directive templates for agent and subagent turns. +//! +//! Centralising all prompt text here keeps the core turn logic free of +//! hardcoded prose, making prompts easier to maintain, review, and localise. +//! +//! # Flow +//! The application layer's `AgentTurnServiceImpl` calls `main_agent_prompt()` +//! to construct the system message at the start of each turn. Subagent and +//! review prompts are provided by their respective modules. + +/// Build the main-agent system prompt. +/// +/// The prompt establishes the agent's identity as Zesdex, an AI coding +/// assistant, and defines the priority hierarchy that governs tool selection: +/// +/// 1. **Workflow first** — `workflow_run` / `hive_mind` for complex tasks +/// 2. **Planning & TODOs** — `plan_enter` / `todowrite` for structural work +/// 3. **Reasoning** — `seq_think` for deep analysis +/// 4. **Tool execution** — direct tools for simple actions +pub fn main_agent_prompt() -> String { + "\ +You are Zesdex, an AI coding assistant. You have access to various tools \ +via native function calling to help the user. + +CRITICAL DIRECTIVES & PRIORITY HIERARCHY: +1. WORKFLOW FIRST: For any multi-step, complex, or non-trivial task, \ +you MUST prioritise using `workflow_run` (to construct and execute a \ +multi-phase YAML workflow) or `hive_mind` (to orchestrate parallel \ +autonomous agents). Workflows are your primary strategy. +2. PLANNING & TODOS: Use `plan_enter` to establish high-level \ +architectural plans and `todowrite` to maintain granular task checklists. +3. REASONING: Use `seq_think` for deep step-by-step analysis. +4. TOOL EXECUTION: Execute individual tools (file edits, terminal commands) \ +within or guided by your workflows. If an error occurs, analyse and fix it. + +Respond conversationally, concisely, and helpfully." + .to_string() +} + +/// Build a subagent directive prompt. +/// +/// The directive is embedded in a system message that also communicates the +/// current working directory and workspace root so the subagent can resolve +/// paths correctly. +pub fn subagent_directive(directive: &str, cwd: &str, ws_root: &str) -> String { + format!( + "\ +You are a focused subagent. + +Current directory (PWD): {cwd} +Workspace root: {ws_root} + +Your directive: +{directive} + +Complete the directive autonomously using the tools available to you. \ +Return your final answer when done." + ) +} + +/// Build a conversation-compaction prompt. +/// +/// The LLM is asked to produce a concise bulleted summary of the key +/// requests, decisions, tools executed, and files modified. +pub fn compaction_prompt() -> String { + "\ +You are a helpful assistant summarising conversation history. \ +Provide a concise summary of the key user requests, decisions, tools \ +executed, and modified files. Format as a clear bulleted list." + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn main_prompt_is_non_empty() { + let prompt = main_agent_prompt(); + assert!(!prompt.is_empty()); + assert!(prompt.contains("Zesdex")); + assert!(prompt.contains("WORKFLOW FIRST")); + } + + #[test] + fn subagent_directive_includes_directive_text() { + let prompt = subagent_directive("test directive", "/home", "/home/project"); + assert!(prompt.contains("test directive")); + assert!(prompt.contains("/home")); + assert!(prompt.contains("/home/project")); + } +} diff --git a/apps/domain/src/lib.rs b/apps/domain/src/lib.rs index dd94f9d..4fa7b24 100644 --- a/apps/domain/src/lib.rs +++ b/apps/domain/src/lib.rs @@ -53,6 +53,11 @@ pub use core::{ ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats, }; pub use error::DomainError; + +// Agent module top-level items (TurnEvent, SessionRuntime, etc.) pub use agent::*; +// Sub-module items need explicit re-exports +pub use agent::progress::AgentProgress; +pub use agent::prompt::{compaction_prompt, main_agent_prompt, subagent_directive}; pub use workflow::*; pub use subagent::*; diff --git a/apps/infrastructure/src/subagent/engine.rs b/apps/infrastructure/src/subagent/engine.rs index c017e72..92d2bf7 100644 --- a/apps/infrastructure/src/subagent/engine.rs +++ b/apps/infrastructure/src/subagent/engine.rs @@ -3,6 +3,10 @@ //! Flow: construct system message → call LLM → parse tool calls → execute //! tools → continue until the model returns a final text response (no more //! tool calls) or the iteration limit is reached. +//! +//! Progress reporting: when a `TurnEvent` queue is available via the +//! `ToolCtx`, the engine emits `AgentProgress` events so the TUI can show +//! which tool the subagent is currently executing. use anyhow::Result; use tracing::{debug, info, instrument}; @@ -11,17 +15,44 @@ use crate::llm::provider::LlmClient; use crate::subagent::context::SubagentContext; use crate::subagent::division::{tools_for, AccessTier}; use crate::tools::{tool_defs, ToolCtx}; +use zesdex_domain::agent::progress::AgentProgress; use zesdex_domain::core::tool_call::sanitize_tool_arguments; use zesdex_domain::core::ChatMessage; +use zesdex_domain::subagent_directive; /// Maximum number of tool-call iterations before the engine gives up. const MAX_ITERATIONS: u32 = 25; +/// Emit an `AgentProgress` event onto the turn-event queue, if one is +/// configured in the `ToolCtx`. +fn report_progress(tool_ctx: &ToolCtx, progress: AgentProgress) { + if let Some(ref queue) = tool_ctx.turn_events { + if let Ok(mut q) = queue.lock() { + q.push_back(zesdex_domain::agent::TurnEvent::AgentProgress(progress)); + } + } +} + +/// Build the system message for a subagent, including current working +/// directory and workspace root information. +fn build_system_message(directive: &str, tool_ctx: &ToolCtx) -> ChatMessage { + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let ws_root = tool_ctx + .workspaces + .first() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| cwd.clone()); + + ChatMessage::system(subagent_directive(directive, &cwd, &ws_root)) +} + /// Run an agent with a directive, access tier, and tool context. /// /// Flow: /// 1. Resolve allowed tools for the given `access` tier. -/// 2. Build a system prompt from the directive. +/// 2. Build a system prompt from the directive using the domain prompt module. /// 3. Loop (up to `MAX_ITERATIONS`): /// a. Call the LLM (non-streaming) with accumulated messages + tool defs. /// b. If the response has no tool calls → return the text content. @@ -29,6 +60,9 @@ const MAX_ITERATIONS: u32 = 25; /// tool-role message. /// d. If the response also contained text, append an assistant message. /// 4. If the loop exits naturally, return the iteration-limit message. +/// +/// Progress: each tool invocation is reported via `AgentProgress` if a +/// turn-event queue is available in the `ToolCtx`. #[instrument(skip(ctx, tool_ctx))] pub async fn run_agent( ctx: SubagentContext, @@ -41,23 +75,9 @@ pub async fn run_agent( let tools = tools_for(&access); let defs = tool_defs(&tools); - let cwd = std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "unknown".to_string()); - let ws_root = tool_ctx - .workspaces - .first() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|| cwd.clone()); + let sys_msg = build_system_message(directive, &tool_ctx); - let mut messages = vec![ChatMessage::system(format!( - "You are a focused subagent.\n\n\ - Current directory (PWD): {cwd}\n\ - Workspace root: {ws_root}\n\n\ - Your directive:\n{directive}\n\n\ - Complete the directive autonomously using the tools available to you. \ - Return your final answer when done." - ))]; + let mut messages = vec![sys_msg]; let client = LlmClient::new( ctx.api_key.clone(), @@ -68,12 +88,9 @@ pub async fn run_agent( // Limited iteration loop so we don't run forever for iteration in 0..MAX_ITERATIONS { use zesdex_application::ports::ProviderService; - let (response_msg, _usage) = client.chat( - &messages, - Some(defs.clone()), - Some(4096), - None, - ).await?; + let (response_msg, _usage) = client + .chat(&messages, Some(defs.clone()), Some(4096), None) + .await?; let content = response_msg.content.clone().unwrap_or_default(); let tool_calls = response_msg.tool_calls.unwrap_or_default(); @@ -81,6 +98,10 @@ pub async fn run_agent( // If no tool calls, we're done — return content if tool_calls.is_empty() { info!("Subagent completed after {iteration} iterations"); + report_progress( + &tool_ctx, + AgentProgress::completed("subagent", directive), + ); return Ok(content); } @@ -91,14 +112,24 @@ pub async fn run_agent( debug!("Subagent executing tool: {tool_name}"); - let result = if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) { - match tool.run(&tool_ctx, &args) { - Ok(output) => output, - Err(e) => format!("Error: {e}"), - } - } else { - format!("Unknown tool: {tool_name}") - }; + report_progress( + &tool_ctx, + AgentProgress::running( + "subagent", + format!("{}:{}", directive, tool_name), + Some(tool_name.clone()), + ), + ); + + let result = + if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) { + match tool.run(&tool_ctx, &args) { + Ok(output) => output, + Err(e) => format!("Error: {e}"), + } + } else { + format!("Unknown tool: {tool_name}") + }; messages.push(ChatMessage::tool(tc.id.clone(), result)); } @@ -109,5 +140,14 @@ pub async fn run_agent( } } - Ok("Subagent reached iteration limit".to_string()) + info!("Subagent reached iteration limit ({MAX_ITERATIONS})"); + report_progress( + &tool_ctx, + AgentProgress::failed( + "subagent", + directive, + format!("iteration limit ({MAX_ITERATIONS})"), + ), + ); + Ok(format!("Subagent reached iteration limit ({MAX_ITERATIONS})")) } diff --git a/apps/infrastructure/src/tools/context.rs b/apps/infrastructure/src/tools/context.rs new file mode 100644 index 0000000..4ba2fcd --- /dev/null +++ b/apps/infrastructure/src/tools/context.rs @@ -0,0 +1,109 @@ +//! Tool execution context: shared state passed to every `Tool::run` call. + +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; + +/// Shared execution context passed to every `Tool::run` call: workspace roots, +/// session paths, cached directory state, and workflow-level findings sharing. +#[derive(Clone)] +pub struct ToolCtx { + pub workspaces: Vec, + pub session_dir: PathBuf, + pub memory_dir: PathBuf, + pub worktrees_dir: PathBuf, + pub dir_cache: Arc>, + pub mention_index: crate::MentionIndex, + pub origin: crate::Origin, + pub graduated_checks: Vec, + pub lsp_manager: Arc>, + pub turn_events: + Option>>>, + pub workflow_findings: Option>>>, + pub abort_flag: Option>, +} + +impl ToolCtx { + pub fn builder() -> ToolCtxBuilder { + ToolCtxBuilder::default() + } +} + +/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`. +#[derive(Clone)] +pub struct ToolCtxBuilder { + pub workspaces: Vec, + pub session_dir: PathBuf, + pub memory_dir: PathBuf, + pub worktrees_dir: PathBuf, + pub dir_cache: Arc>, + pub mention_index: crate::MentionIndex, + pub origin: crate::Origin, + pub graduated_checks: Vec, + pub lsp_manager: Arc>, + pub turn_events: + Option>>>, + pub workflow_findings: Option>>>, + pub abort_flag: Option>, +} + +impl Default for ToolCtxBuilder { + fn default() -> Self { + ToolCtxBuilder { + workspaces: Vec::new(), + session_dir: PathBuf::new(), + memory_dir: PathBuf::new(), + worktrees_dir: PathBuf::new(), + dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())), + mention_index: crate::MentionIndex::new(), + origin: crate::Origin::Main, + graduated_checks: Vec::new(), + lsp_manager: Arc::new(Mutex::new(crate::lsp::manager::LspManager::new())), + turn_events: None, + workflow_findings: None, + abort_flag: None, + } + } +} + +impl ToolCtxBuilder { + pub fn session_dir(mut self, v: PathBuf) -> Self { + self.session_dir = v; + self + } + pub fn workspaces(mut self, v: Vec) -> Self { + self.workspaces = v; + self + } + pub fn origin(mut self, v: crate::Origin) -> Self { + self.origin = v; + self + } + pub fn turn_events( + mut self, + v: Arc>>, + ) -> Self { + self.turn_events = Some(v); + self + } + pub fn workflow_findings(mut self, v: Option>>>) -> Self { + self.workflow_findings = v; + self + } + pub fn build(self) -> ToolCtx { + ToolCtx { + workspaces: self.workspaces, + session_dir: self.session_dir, + memory_dir: self.memory_dir, + worktrees_dir: self.worktrees_dir, + dir_cache: self.dir_cache, + mention_index: self.mention_index, + origin: self.origin, + graduated_checks: self.graduated_checks, + lsp_manager: self.lsp_manager, + turn_events: self.turn_events, + workflow_findings: self.workflow_findings, + abort_flag: self.abort_flag, + } + } +} diff --git a/apps/infrastructure/src/tools/graduated.rs b/apps/infrastructure/src/tools/graduated.rs new file mode 100644 index 0000000..97bc439 --- /dev/null +++ b/apps/infrastructure/src/tools/graduated.rs @@ -0,0 +1,26 @@ +//! Graduated check rules: project-defined patterns that flag matching +//! file paths or content for review during write/edit operations. + +/// A project-defined rule that flags a matching file path or content pattern +/// for review. +#[derive(Debug, Clone)] +pub struct GraduatedCheck { + pub name: String, + pub pattern: String, + pub rule: String, +} + +/// Check which graduated checks apply to a given file path/content pair. +pub fn check_graduated_checks( + path: &str, + content: &str, + checks: &[GraduatedCheck], +) -> Vec { + let mut matches = Vec::new(); + for check in checks { + if path.contains(&check.pattern) || content.contains(&check.rule) { + matches.push(check.name.clone()); + } + } + matches +} diff --git a/apps/infrastructure/src/tools/mod.rs b/apps/infrastructure/src/tools/mod.rs index 860c219..8c7b56a 100644 --- a/apps/infrastructure/src/tools/mod.rs +++ b/apps/infrastructure/src/tools/mod.rs @@ -1,26 +1,50 @@ //! Tool trait, execution context, and the registry of all built-in tools. //! //! This module defines the core `Tool` trait that every agent-invocable tool -//! must implement, the shared `ToolCtx` execution context passed to every tool -//! invocation, and utility functions for path resolution, command execution, -//! argument extraction, and edit-log persistence. +//! must implement, the shared `ToolCtx` execution context, and utility +//! functions for path resolution, command execution, argument extraction, +//! and graduated-check rules. +//! +//! # Organisation +//! +//! ```text +//! tools/ +//! ├── mod.rs — Tool trait, re-exports +//! ├── context.rs — ToolCtx, ToolCtxBuilder +//! ├── registry.rs — all_tools(), tool_defs(), tool_is_risky() +//! ├── util.rs — arg_str(), execute_cmd(), resolve_path(), +//! │ log_write_edit_tool() +//! ├── graduated.rs — GraduatedCheck, check_graduated_checks() +//! ├── executor.rs — InfrastructureToolExecutor +//! ├── fs/ — read, write, edit, delete +//! ├── git/ — git_operator, git_worktree, git_cred +//! ├── lsp/ — connect, disconnect, diagnostics, completion, etc. +//! ├── memory/ — remember, forget, recall +//! ├── utility/ — cd, dir_list, pong, todowrite, todofinish, etc. +//! ├── shell.rs — Bash tool +//! ├── bash_tools.rs +//! ├── search.rs — Grep, Glob +//! ├── semantic_search.rs +//! ├── web_search.rs +//! ├── sequential_think.rs +//! ├── plan.rs, spawn.rs, workflow.rs +//! └── parallel_delegate.rs +//! ``` -use crate::utils::CastOr; use anyhow::Result; use serde_json::Value; -use sha2::Digest; -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use std::sync::{Arc, Mutex}; -use tracing::{debug, info, instrument, warn}; pub mod bash_tools; +pub mod context; +pub mod executor; pub mod fs; pub mod git; +pub mod graduated; pub mod lsp; pub mod memory; pub mod parallel_delegate; pub mod plan; +pub mod registry; pub mod search; pub mod semantic_search; pub mod sequential_think; @@ -28,13 +52,16 @@ pub mod shell; pub mod shell_filter; pub mod spawn; pub mod utility; +pub mod util; pub mod web_search; pub mod workflow; -pub mod executor; -pub use git::git_cred; -pub use git::git_operator; -pub use git::git_worktree; +// Re-export commonly used items at the `tools` root so existing imports +// like `crate::tools::{Tool, ToolCtx}` continue to work. +pub use context::{ToolCtx, ToolCtxBuilder}; +pub use graduated::{check_graduated_checks, GraduatedCheck}; +pub use registry::{all_tools, tool_defs, tool_is_risky}; +pub use util::{arg_str, execute_cmd, log_write_edit_tool, resolve_path}; /// Common interface every agent-invocable tool implements. pub trait Tool: Send + Sync { @@ -44,340 +71,6 @@ pub trait Tool: Send + Sync { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result; } -/// A project-defined rule that flags a matching file path or content pattern -/// for review. -#[derive(Debug, Clone)] -pub struct GraduatedCheck { - pub name: String, - pub pattern: String, - pub rule: String, -} - -/// Shared execution context passed to every `Tool::run` call: workspace roots, -/// session paths, cached directory state, and workflow-level findings sharing. -#[derive(Clone)] -pub struct ToolCtx { - pub workspaces: Vec, - pub session_dir: PathBuf, - pub memory_dir: PathBuf, - pub worktrees_dir: PathBuf, - pub dir_cache: Arc>, - pub mention_index: crate::MentionIndex, - pub origin: crate::Origin, - pub graduated_checks: Vec, - pub lsp_manager: Arc>, - pub turn_events: - Option>>>, - pub workflow_findings: Option>>>, - pub abort_flag: Option>, -} - -impl ToolCtx { - pub fn builder() -> ToolCtxBuilder { - ToolCtxBuilder::default() - } -} - -/// Builder for `ToolCtx`; fields default to empty paths and a fresh `DirCache`. -#[derive(Clone)] -pub struct ToolCtxBuilder { - pub workspaces: Vec, - pub session_dir: PathBuf, - pub memory_dir: PathBuf, - pub worktrees_dir: PathBuf, - pub dir_cache: Arc>, - pub mention_index: crate::MentionIndex, - pub origin: crate::Origin, - pub graduated_checks: Vec, - pub lsp_manager: Arc>, - pub turn_events: - Option>>>, - pub workflow_findings: Option>>>, - pub abort_flag: Option>, -} - -impl Default for ToolCtxBuilder { - fn default() -> Self { - ToolCtxBuilder { - workspaces: Vec::new(), - session_dir: PathBuf::new(), - memory_dir: PathBuf::new(), - worktrees_dir: PathBuf::new(), - dir_cache: Arc::new(tokio::sync::RwLock::new(crate::DirCache::new())), - mention_index: crate::MentionIndex::new(), - origin: crate::Origin::Main, - graduated_checks: Vec::new(), - lsp_manager: Arc::new(Mutex::new(crate::lsp::manager::LspManager::new())), - turn_events: None, - workflow_findings: None, - abort_flag: None, - } - } -} - -impl ToolCtxBuilder { - pub fn session_dir(mut self, v: PathBuf) -> Self { - self.session_dir = v; - self - } - pub fn workspaces(mut self, v: Vec) -> Self { - self.workspaces = v; - self - } - pub fn origin(mut self, v: crate::Origin) -> Self { - self.origin = v; - self - } - pub fn turn_events( - mut self, - v: Arc>>, - ) -> Self { - self.turn_events = Some(v); - self - } - pub fn workflow_findings(mut self, v: Option>>>) -> Self { - self.workflow_findings = v; - self - } - pub fn build(self) -> ToolCtx { - ToolCtx { - workspaces: self.workspaces, - session_dir: self.session_dir, - memory_dir: self.memory_dir, - worktrees_dir: self.worktrees_dir, - dir_cache: self.dir_cache, - mention_index: self.mention_index, - origin: self.origin, - graduated_checks: self.graduated_checks, - lsp_manager: self.lsp_manager, - turn_events: self.turn_events, - workflow_findings: self.workflow_findings, - abort_flag: self.abort_flag, - } - } -} - -/// Check which graduated checks apply to a given file path/content pair. -pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec { - let mut matches = Vec::new(); - for check in checks { - if path.contains(&check.pattern) || content.contains(&check.rule) { - matches.push(check.name.clone()); - } - } - matches -} - -/// Construct one instance of every built-in tool. -pub fn all_tools() -> Vec> { - vec![ - Box::new(fs::read::Read), - Box::new(fs::write::Write), - Box::new(fs::edit::Edit), - Box::new(fs::delete::Delete), - Box::new(search::Grep), - Box::new(search::Glob), - Box::new(bash_tools::BashOutput), - Box::new(bash_tools::BashKill), - Box::new(shell::Bash), - Box::new(git_operator::GitOperator), - Box::new(git_worktree::GitWorktree), - Box::new(git_cred::GitCred), - Box::new(sequential_think::SeqThink), - Box::new(plan::PlanEnter), - Box::new(plan::PlanReady), - Box::new(workflow::WorkflowRun), - Box::new(workflow::NoteFinding), - Box::new(workflow::ReadFindings), - Box::new(workflow::HiveMind), - Box::new(spawn::SpawnAgents), - Box::new(spawn::SpawnPipeline), - Box::new(memory::remember::Remember), - Box::new(memory::forget::Forget), - Box::new(memory::recall::Recall), - Box::new(utility::cd::Cd), - Box::new(utility::dir_list::DirList), - Box::new(utility::dir_cache_update::DirCacheUpdate), - Box::new(utility::pong::Pong), - Box::new(utility::todowrite::Todowrite), - Box::new(utility::todofinish::Todofinish), - Box::new(lsp::LspConnect), - Box::new(lsp::LspDiagnostics), - Box::new(lsp::LspHover), - Box::new(lsp::LspCompletion), - Box::new(lsp::LspDefinition), - Box::new(lsp::LspReferences), - Box::new(lsp::LspDisconnect), - Box::new(web_search::WebSearch), - Box::new(semantic_search::SemanticSearch), - Box::new(semantic_search::RebuildIndex), - Box::new(parallel_delegate::ParallelDelegate), - ] -} - -/// Whether a tool by name can mutate the filesystem or run arbitrary shell commands. -pub fn tool_is_risky(name: &str) -> bool { - matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") -} - -/// Extract a required string argument from a JSON args map. -pub fn arg_str(args: &Value, name: &str) -> Result { - args.get(name) - .and_then(|v| v.as_str()) - .map(std::string::ToString::to_string) - .ok_or_else(|| anyhow::anyhow!("missing required argument: {name}")) -} - -/// Execute a `std::process::Command` and return its combined stdout/stderr. -/// -/// Flow: spawn → collect stdout + stderr → check exit code → return combined output -/// or bail with the error message. -#[instrument(skip(cmd))] -pub fn execute_cmd(cmd: &mut std::process::Command) -> Result { - let output = cmd - .output() - .map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout_len = stdout.len(); - let stderr_len = stderr.len(); - let combined = if stderr.is_empty() { - stdout - } else { - format!("{}\n{}", stdout, stderr) - .trim() - .to_string() - }; - let code = output.status.code().unwrap_or(-1); - if output.status.success() { - info!(exit_code = code, stdout_len, "command succeeded"); - Ok(combined) - } else { - warn!(exit_code = code, stderr_len, "command failed"); - anyhow::bail!("command failed with exit code {code}:\n{combined}") - } -} - -/// Resolve a tool-supplied relative path to an absolute path within a workspace -/// root, rejecting escapes. -/// -/// Flow: parse optional `[idx]` prefix → join with workspace root → canonicalize -/// → verify result is inside one of the workspace roots. -#[instrument(skip(workspaces))] -pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { - let (ws_idx, path) = if rel.starts_with('[') { - let close = rel - .find(']') - .ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; - let idx: usize = rel[1..close] - .parse() - .map_err(|_| anyhow::anyhow!("invalid workspace index"))?; - (idx, &rel[close + 1..]) - } else { - (0, rel) - }; - let base = workspaces - .get(ws_idx) - .ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?; - let abs = if path.is_empty() { - base.clone() - } else { - base.join(path) - }; - let canon = if let Ok(c) = abs.canonicalize() { - c - } else { - let base_canon = workspaces - .iter() - .find_map(|w| w.canonicalize().ok()) - .unwrap_or_else(|| base.clone()); - let mut resolved = base_canon.clone(); - if let Ok(rel_components) = abs.strip_prefix(&base_canon) { - for comp in rel_components.components() { - match comp { - std::path::Component::ParentDir => { - resolved.pop(); - } - std::path::Component::CurDir => {} - c => resolved.push(c), - } - } - } - resolved - }; - debug!(resolved = %canon.display(), "path resolved within workspace"); - if workspaces.iter().any(|w| canon.starts_with(w)) { - Ok(canon) - } else { - warn!(path = %canon.display(), rel = rel, "path is outside all workspace roots"); - anyhow::bail!("path '{rel}' is outside all workspace roots") - } -} - -/// After a successful write/edit tool run, compute content hash and byte -/// delta, then persist an `EditLogEntry` to the session's edit log. -/// -/// Flow: extract path/content/reason from args → compute SHA-256 of content -/// → compute byte delta → build `EditLogEntry` → open repo → append entry. -#[instrument(skip(args, session_dir))] -pub fn log_write_edit_tool( - args: &serde_json::Value, - tool_name: &str, - origin_tag: &str, - session_dir: &std::path::Path, - session_id: &str, -) { - let reason = args - .get("reason") - .and_then(|v| v.as_str()) - .unwrap_or("unnamed"); - let path = args - .get("path") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let content = args.get("content").or_else(|| args.get("new")); - let content_str = content.and_then(|v| v.as_str()).unwrap_or(""); - let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes())); - let bytes_delta = if tool_name == "write" { - content_str.len().cast_or(0i64) - } else { - let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); - let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); - let new_len: i64 = new.len().cast_or(0i64); - let old_len: i64 = old.len().cast_or(0i64); - (new_len - old_len).abs() - }; - let entry = zesdex_domain::cms::EditLogEntry { - ts: chrono::Utc::now().timestamp_millis(), - tool: tool_name.to_string(), - path: path.to_string(), - reason: reason.to_string(), - content_sha256, - bytes_delta, - origin: origin_tag.to_string(), - session_id: session_id.to_string(), - }; - use zesdex_domain::cms::repository::EditLogRepository; - let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new(); - if let Ok(mut el) = repo.open(session_dir) { - let _ = repo.append(session_dir, &mut el, entry); - debug!(tool = tool_name, path = path, "edit-log entry persisted"); - } else { - warn!(tool = tool_name, path = path, "failed to open edit-log repository"); - } -} - -/// Convert a list of tools into provider-facing `ToolDef` request schema. -pub fn tool_defs(tools: &[Box]) -> Vec { - tools - .iter() - .map(|t| zesdex_domain::core::ToolDef { - type_: "function".to_string(), - function: zesdex_domain::core::ToolFunctionDef { - name: t.name().to_string(), - description: t.description().to_string(), - parameters: t.parameters(), - }, - }) - .collect() -} +pub use git::git_cred; +pub use git::git_operator; +pub use git::git_worktree; diff --git a/apps/infrastructure/src/tools/registry.rs b/apps/infrastructure/src/tools/registry.rs new file mode 100644 index 0000000..a550156 --- /dev/null +++ b/apps/infrastructure/src/tools/registry.rs @@ -0,0 +1,70 @@ +//! Tool registry: the master list of all built-in tools, plus conversion +//! utilities for generating provider-facing tool definitions. + +/// Construct one instance of every built-in tool. +pub fn all_tools() -> Vec> { + vec![ + Box::new(super::fs::read::Read), + Box::new(super::fs::write::Write), + Box::new(super::fs::edit::Edit), + Box::new(super::fs::delete::Delete), + Box::new(super::search::Grep), + Box::new(super::search::Glob), + Box::new(super::bash_tools::BashOutput), + Box::new(super::bash_tools::BashKill), + Box::new(super::shell::Bash), + Box::new(super::git::git_operator::GitOperator), + Box::new(super::git::git_worktree::GitWorktree), + Box::new(super::git::git_cred::GitCred), + Box::new(super::sequential_think::SeqThink), + Box::new(super::plan::PlanEnter), + Box::new(super::plan::PlanReady), + Box::new(super::workflow::WorkflowRun), + Box::new(super::workflow::NoteFinding), + Box::new(super::workflow::ReadFindings), + Box::new(super::workflow::HiveMind), + Box::new(super::spawn::SpawnAgents), + Box::new(super::spawn::SpawnPipeline), + Box::new(super::memory::remember::Remember), + Box::new(super::memory::forget::Forget), + Box::new(super::memory::recall::Recall), + Box::new(super::utility::cd::Cd), + Box::new(super::utility::dir_list::DirList), + Box::new(super::utility::dir_cache_update::DirCacheUpdate), + Box::new(super::utility::pong::Pong), + Box::new(super::utility::todowrite::Todowrite), + Box::new(super::utility::todofinish::Todofinish), + Box::new(super::lsp::LspConnect), + Box::new(super::lsp::LspDiagnostics), + Box::new(super::lsp::LspHover), + Box::new(super::lsp::LspCompletion), + Box::new(super::lsp::LspDefinition), + Box::new(super::lsp::LspReferences), + Box::new(super::lsp::LspDisconnect), + Box::new(super::web_search::WebSearch), + Box::new(super::semantic_search::SemanticSearch), + Box::new(super::semantic_search::RebuildIndex), + Box::new(super::parallel_delegate::ParallelDelegate), + ] +} + +/// Whether a tool by name can mutate the filesystem or run arbitrary shell +/// commands. +pub fn tool_is_risky(name: &str) -> bool { + matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") +} + +/// Convert a list of tools into provider-facing `ToolDef` request schema. +pub fn tool_defs(tools: &[Box]) -> Vec { + tools + .iter() + .map(|t| zesdex_domain::core::ToolDef { + type_: "function".to_string(), + function: zesdex_domain::core::ToolFunctionDef { + name: t.name().to_string(), + description: t.description().to_string(), + parameters: t.parameters(), + }, + }) + .collect() +} diff --git a/apps/infrastructure/src/tools/util.rs b/apps/infrastructure/src/tools/util.rs new file mode 100644 index 0000000..f907e49 --- /dev/null +++ b/apps/infrastructure/src/tools/util.rs @@ -0,0 +1,156 @@ +//! Shared utility functions used by tool implementations: JSON argument +//! extraction, command execution, path resolution, and edit-log persistence. + +use crate::utils::CastOr; +use anyhow::Result; +use serde_json::Value; +use sha2::Digest; +use std::path::PathBuf; +use tracing::{debug, info, instrument, warn}; + +/// Extract a required string argument from a JSON args map. +pub fn arg_str(args: &Value, name: &str) -> Result { + args.get(name) + .and_then(|v| v.as_str()) + .map(std::string::ToString::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required argument: {name}")) +} + +/// Execute a `std::process::Command` and return its combined stdout/stderr. +/// +/// Flow: spawn \u{2192} collect stdout + stderr \u{2192} check exit code \u{2192} return +/// combined output or bail with the error message. +#[instrument(skip(cmd))] +pub fn execute_cmd(cmd: &mut std::process::Command) -> Result { + let output = cmd + .output() + .map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout_len = stdout.len(); + let stderr_len = stderr.len(); + let combined = if stderr.is_empty() { + stdout + } else { + format!("{}\n{}", stdout, stderr) + .trim() + .to_string() + }; + let code = output.status.code().unwrap_or(-1); + if output.status.success() { + info!(exit_code = code, stdout_len, "command succeeded"); + Ok(combined) + } else { + warn!(exit_code = code, stderr_len, "command failed"); + anyhow::bail!("command failed with exit code {code}:\n{combined}") + } +} + +/// Resolve a tool-supplied relative path to an absolute path within a workspace +/// root, rejecting escapes. +/// +/// Flow: parse optional `[idx]` prefix \u{2192} join with workspace root \u{2192} +/// canonicalize \u{2192} verify result is inside one of the workspace roots. +#[instrument(skip(workspaces))] +pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { + let (ws_idx, path) = if rel.starts_with('[') { + let close = rel + .find(']') + .ok_or_else(|| anyhow::anyhow!("invalid workspace prefix"))?; + let idx: usize = rel[1..close] + .parse() + .map_err(|_| anyhow::anyhow!("invalid workspace index"))?; + (idx, &rel[close + 1..]) + } else { + (0, rel) + }; + let base = workspaces + .get(ws_idx) + .ok_or_else(|| anyhow::anyhow!("workspace index {ws_idx} out of range"))?; + let abs = if path.is_empty() { + base.clone() + } else { + base.join(path) + }; + let canon = if let Ok(c) = abs.canonicalize() { + c + } else { + let base_canon = workspaces + .iter() + .find_map(|w| w.canonicalize().ok()) + .unwrap_or_else(|| base.clone()); + let mut resolved = base_canon.clone(); + if let Ok(rel_components) = abs.strip_prefix(&base_canon) { + for comp in rel_components.components() { + match comp { + std::path::Component::ParentDir => { + resolved.pop(); + } + std::path::Component::CurDir => {} + c => resolved.push(c), + } + } + } + resolved + }; + debug!(resolved = %canon.display(), "path resolved within workspace"); + if workspaces.iter().any(|w| canon.starts_with(w)) { + Ok(canon) + } else { + warn!(path = %canon.display(), rel = rel, "path is outside all workspace roots"); + anyhow::bail!("path '{rel}' is outside all workspace roots") + } +} + +/// After a successful write/edit tool run, compute content hash and byte +/// delta, then persist an `EditLogEntry` to the session's edit log. +/// +/// Flow: extract path/content/reason from args \u{2192} compute SHA-256 of content +/// \u{2192} compute byte delta \u{2192} build `EditLogEntry` \u{2192} open repo \u{2192} append entry. +#[instrument(skip(args, session_dir))] +pub fn log_write_edit_tool( + args: &serde_json::Value, + tool_name: &str, + origin_tag: &str, + session_dir: &std::path::Path, + session_id: &str, +) { + let reason = args + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("unnamed"); + let path = args + .get("path") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let content = args.get("content").or_else(|| args.get("new")); + let content_str = content.and_then(|v| v.as_str()).unwrap_or(""); + let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes())); + let bytes_delta = if tool_name == "write" { + content_str.len().cast_or(0i64) + } else { + let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); + let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); + let new_len: i64 = new.len().cast_or(0i64); + let old_len: i64 = old.len().cast_or(0i64); + (new_len - old_len).abs() + }; + let entry = zesdex_domain::cms::EditLogEntry { + ts: chrono::Utc::now().timestamp_millis(), + tool: tool_name.to_string(), + path: path.to_string(), + reason: reason.to_string(), + content_sha256, + bytes_delta, + origin: origin_tag.to_string(), + session_id: session_id.to_string(), + }; + use zesdex_domain::cms::repository::EditLogRepository; + let repo = crate::persistence::cms::edit_log_repo::JsonlEditLogRepository::new(); + if let Ok(mut el) = repo.open(session_dir) { + let _ = repo.append(session_dir, &mut el, entry); + debug!(tool = tool_name, path = path, "edit-log entry persisted"); + } else { + warn!(tool = tool_name, path = path, "failed to open edit-log repository"); + } +} diff --git a/apps/interfaces/tui/src/action.rs b/apps/interfaces/tui/src/action.rs deleted file mode 100644 index d1a82f6..0000000 --- a/apps/interfaces/tui/src/action.rs +++ /dev/null @@ -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` → 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 = 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 -} diff --git a/apps/interfaces/tui/src/action/git.rs b/apps/interfaces/tui/src/action/git.rs new file mode 100644 index 0000000..8f18277 --- /dev/null +++ b/apps/interfaces/tui/src/action/git.rs @@ -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 +} diff --git a/apps/interfaces/tui/src/action/handlers.rs b/apps/interfaces/tui/src/action/handlers.rs new file mode 100644 index 0000000..40953df --- /dev/null +++ b/apps/interfaces/tui/src/action/handlers.rs @@ -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 = 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; +} diff --git a/apps/interfaces/tui/src/action/mod.rs b/apps/interfaces/tui/src/action/mod.rs new file mode 100644 index 0000000..04d68e4 --- /dev/null +++ b/apps/interfaces/tui/src/action/mod.rs @@ -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` \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(); + } + } +} diff --git a/apps/interfaces/tui/src/state.rs b/apps/interfaces/tui/src/state.rs deleted file mode 100644 index 18b6416..0000000 --- a/apps/interfaces/tui/src/state.rs +++ /dev/null @@ -1,1116 +0,0 @@ -//! 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 `controller/input.rs` and read by `view/` every render frame. -//! -//! Infrastructure types (SessionRuntime, DirCache, Toast, etc.) are imported -//! from `zesdex_infrastructure`; domain types (Settings, AppConfig, Role) -//! come from `zesdex_domain`. -//! -//! # Flow -//! Construction in `lib.rs::create_tui_state` → mutated by key events in -//! `controller/input.rs::handle_key` → 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}; - -// --------------------------------------------------------------------------- -// Transcript display type -// --------------------------------------------------------------------------- - -/// A single transcript entry rendered in the TUI chat pane. -#[derive(Debug, Clone, PartialEq)] -pub struct ChatMessageDisplay { - /// Message author: User or Assistant. - pub role: zesdex_domain::core::Role, - /// Rendered text content (plain text, no markdown). - pub content: String, - /// Model reasoning (e.g. from DeepSeek-R1 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: zesdex_domain::core::Role, content: String) -> Self { - ChatMessageDisplay { - role, - content, - reasoning: String::new(), - timestamp: chrono::Utc::now().timestamp_millis(), - } - } -} - -// --------------------------------------------------------------------------- -// Bounded ring-buffer transcript cache -// --------------------------------------------------------------------------- - -/// 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, - /// 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, - } - } -} - -// --------------------------------------------------------------------------- -// Scroll state -// --------------------------------------------------------------------------- - -/// 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() - } -} - -// --------------------------------------------------------------------------- -// Input state (buffer, cursor, history, autocomplete) -// --------------------------------------------------------------------------- - -/// 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, - /// Index into `history` when browsing (None = at the current input). - pub history_idx: Option, - /// Current autocomplete candidate list. - pub autocomplete_candidates: Vec, - /// 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, -} - -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() - } -} - -// --------------------------------------------------------------------------- -// Overlay enum -// --------------------------------------------------------------------------- - -/// 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()) - } -} - -// --------------------------------------------------------------------------- -// MiscState — overlay, toasts, flags, tick, editor -// --------------------------------------------------------------------------- - -/// 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, - /// 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, - /// 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, - /// 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 { - 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() - } -} - -// --------------------------------------------------------------------------- -// EditorState — used by the Editor overlay -// --------------------------------------------------------------------------- - -/// 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); - } - } -} - -// --------------------------------------------------------------------------- -// AgentState + SimpleAgent + SimpleWorkflowEngine (workflow display) -// --------------------------------------------------------------------------- - -/// 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, - /// Millisecond timestamp when the agent completed. - pub completed_at: Option, - /// Optional error message if the agent failed. - pub error: Option, - /// Optional progress text (current tool, step description). - pub progress: Option, -} - -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, - /// Summary findings produced by completed agents. - pub findings: Vec, -} - -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() - } -} - -// --------------------------------------------------------------------------- -// Effort levels (for effort overlay) -// --------------------------------------------------------------------------- - -/// Name of each reasoning-effort tier. -pub const EFFORT_LEVELS: &[&str] = &[ - "Auto — let the provider decide", - "Low — fast, minimal reasoning", - "Medium — balanced speed & reasoning", - "High — thorough reasoning", - "Maximum — 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 item types (for learning overlay) -// --------------------------------------------------------------------------- - -/// 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, - }, -} - -/// Return learning items from state. -/// -/// Reads lesson markdown files from the `lessons/` subdirectory -/// under the memory directory. -#[tracing::instrument(skip(state))] -pub fn get_learning_items(state: &AppStateRest) -> Vec { - 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 -} - -/// 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 } - } -} - -// --------------------------------------------------------------------------- -// Rewind helpers -// --------------------------------------------------------------------------- - -/// Return the number of rewind points available. -pub fn rewind_count(state: &AppStateRest) -> usize { - state.transcript_cache.messages.len() -} - -// --------------------------------------------------------------------------- -// Context window helpers -// --------------------------------------------------------------------------- - -/// 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 { - // Use configured max tokens from settings, or default to 128k - 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 → 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 { - // Try tiktoken for accurate counting - 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) -} - -// --------------------------------------------------------------------------- -// 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, - /// 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>, - /// Shared workspace file-path index for `@file` mention autocomplete. - pub mention_index: MentionIndex, - /// Optional per-session runtime state. - pub session_runtime: Option, - /// 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>>, - /// Whether an agent turn is currently in flight. - /// Uses AtomicBool for lock-free check from render loop. - pub turn_in_flight_flag: Arc, - /// Cached display lines for the chat transcript panel. - /// Rebuilt incrementally — only new messages are appended, not full rebuild. - pub display_lines_cache: Vec>, - /// Cached token count for the current message history. - /// Updated lazily only when new messages arrive, not every frame. - 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. - /// Used to detect incremental vs full rebuild requirement. - pub cached_msg_count: usize, - /// Terminal width at the time of the last full cache build. - /// If this differs from last_render_width, a full rebuild is needed. - pub render_width_at_cache: u16, - /// Atomic flag set when the user aborts the current turn. - pub abort_flag: Arc, - /// 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 — Keyboard Shortcuts - - ─── General ─── - Ctrl+C Quit confirm - Ctrl+D Close overlay - Ctrl+Y Copy last assistant message - Esc Abort turn / Close overlay - Tab Autocomplete - - ─── Navigation ─── - ↑ / ↓ History browse / Overlay navigate - Ctrl+↑/↓ Scroll transcript - PgUp / PgDown Scroll transcript - Enter Submit / Select autocomplete - - ─── Overlays ─── - /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 - - ─── Editor Mode ─── - /edit 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, - 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(zesdex_infrastructure::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 — 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 instead of O(n) Vec::remove(0). - pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { - self.transcript_cache.messages.push_back(msg); - while self.transcript_cache.messages.len() > self.transcript_cache.max_lines { - self.transcript_cache.messages.pop_front(); - } - self.transcript_cache.dirty = true; - 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) { - if let Some(msg) = self.transcript_cache.messages.back_mut() { - if msg.role == zesdex_domain::core::Role::Assistant { - if is_reasoning { - msg.reasoning.push_str(text); - } else { - msg.content.push_str(text); - } - self.transcript_cache.dirty = true; - 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) { - self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Info, msg.into())); - } - - /// Push a success toast. - pub fn toast_success(&mut self, msg: impl Into) { - self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Success, msg.into())); - } - - /// Push a warning toast. - pub fn toast_warning(&mut self, msg: impl Into) { - self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Warning, msg.into())); - } - - /// Push an error toast. - pub fn toast_error(&mut self, msg: impl Into) { - 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, - ) - } -} diff --git a/apps/interfaces/tui/src/state/helpers.rs b/apps/interfaces/tui/src/state/helpers.rs new file mode 100644 index 0000000..1439527 --- /dev/null +++ b/apps/interfaces/tui/src/state/helpers.rs @@ -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 { + 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) +} diff --git a/apps/interfaces/tui/src/state/input.rs b/apps/interfaces/tui/src/state/input.rs new file mode 100644 index 0000000..4420dd8 --- /dev/null +++ b/apps/interfaces/tui/src/state/input.rs @@ -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, + /// Index into `history` when browsing (None = at the current input). + pub history_idx: Option, + /// Current autocomplete candidate list. + pub autocomplete_candidates: Vec, + /// 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, +} + +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() + } +} diff --git a/apps/interfaces/tui/src/state/misc.rs b/apps/interfaces/tui/src/state/misc.rs new file mode 100644 index 0000000..80f9a64 --- /dev/null +++ b/apps/interfaces/tui/src/state/misc.rs @@ -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, + /// 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, + /// 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, + /// 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 { + 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() + } +} diff --git a/apps/interfaces/tui/src/state/mod.rs b/apps/interfaces/tui/src/state/mod.rs new file mode 100644 index 0000000..d02f776 --- /dev/null +++ b/apps/interfaces/tui/src/state/mod.rs @@ -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, + /// 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>, + /// Shared workspace file-path index for `@file` mention autocomplete. + pub mention_index: MentionIndex, + /// Optional per-session runtime state. + pub session_runtime: Option, + /// 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>>, + /// Whether an agent turn is currently in flight. + /// Uses AtomicBool for lock-free check from render loop. + pub turn_in_flight_flag: Arc, + /// Cached display lines for the chat transcript panel. + pub display_lines_cache: Vec>, + /// 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, + /// 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 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, + 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) { + self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Info, msg.into())); + } + + /// Push a success toast. + pub fn toast_success(&mut self, msg: impl Into) { + self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Success, msg.into())); + } + + /// Push a warning toast. + pub fn toast_warning(&mut self, msg: impl Into) { + self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Warning, msg.into())); + } + + /// Push an error toast. + pub fn toast_error(&mut self, msg: impl Into) { + 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, + ) + } +} diff --git a/apps/interfaces/tui/src/state/scroll.rs b/apps/interfaces/tui/src/state/scroll.rs new file mode 100644 index 0000000..f07f82e --- /dev/null +++ b/apps/interfaces/tui/src/state/scroll.rs @@ -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() + } +} diff --git a/apps/interfaces/tui/src/state/transcript.rs b/apps/interfaces/tui/src/state/transcript.rs new file mode 100644 index 0000000..a48b2e5 --- /dev/null +++ b/apps/interfaces/tui/src/state/transcript.rs @@ -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 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, + /// 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; + } + } + } +} diff --git a/apps/interfaces/tui/src/state/workflow.rs b/apps/interfaces/tui/src/state/workflow.rs new file mode 100644 index 0000000..f7e1a93 --- /dev/null +++ b/apps/interfaces/tui/src/state/workflow.rs @@ -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, + /// Millisecond timestamp when the agent completed. + pub completed_at: Option, + /// Optional error message if the agent failed. + pub error: Option, + /// Optional progress text (current tool, step description). + pub progress: Option, +} + +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, + /// Summary findings produced by completed agents. + pub findings: Vec, +} + +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() + } +} diff --git a/apps/interfaces/tui/src/turn.rs b/apps/interfaces/tui/src/turn.rs index 6617b33..f272fb7 100644 --- a/apps/interfaces/tui/src/turn.rs +++ b/apps/interfaces/tui/src/turn.rs @@ -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 { + 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 = 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; }); diff --git a/apps/interfaces/tui/src/view/overlays/usage.rs b/apps/interfaces/tui/src/view/overlays/usage.rs index 149a5ce..aa47b76 100644 --- a/apps/interfaces/tui/src/view/overlays/usage.rs +++ b/apps/interfaces/tui/src/view/overlays/usage.rs @@ -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(