feat(tui): introduce comprehensive state management for TUI interface

- Add AppStateRest as the central state struct for managing TUI state.
- Implement InputState for handling user input, autocomplete, and history.
- Create MiscState to manage overlays, notifications, and editor state.
- Introduce ScrollState for viewport scrolling functionality.
- Develop TranscriptCache for efficient message rendering in the chat pane.
- Implement SimpleAgent and SimpleWorkflowEngine for agent lifecycle management.
- Add helper functions for managing effort levels and token counting.
- Organize state-related modules for better maintainability and clarity.
This commit is contained in:
asepharyana
2026-07-21 06:42:53 +07:00
parent 802346f909
commit 8c58faf292
25 changed files with 2594 additions and 2158 deletions
+176 -105
View File
@@ -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<Mutex<VecDeque<TurnEvent>>>, 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<AtomicBool>,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
) -> Box<dyn FnMut(&StreamEvent) -> 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<T: ToolExecutor>(
tool_executor: &T,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
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<Mutex<VecDeque<TurnEvent>>>, 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<P: ProviderService, T: ToolExecutor> {
provider: Arc<P>,
@@ -24,15 +121,27 @@ impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
tool_defs,
}
}
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
if let Ok(mut q) = queue.lock() {
q.push_back(event);
}
}
fn mark_done(flag: &Arc<AtomicBool>) {
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<AtomicBool>,
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
) -> 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<P: ProviderService, T: ToolExecutor> 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(
&params.turn_events,
TurnEvent::SystemNote {
kind: "info".into(),
@@ -69,58 +176,28 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
debug!("agent turn iteration {iteration}");
Self::push_event(&params.turn_events, TurnEvent::StreamStart);
// ── Stream start + call LLM ─────────────────────────────────
push_event(&params.turn_events, TurnEvent::StreamStart);
let mut req_messages = params.messages.clone();
req_messages.insert(0, sys_msg.clone());
let abort_clone = Arc::clone(&params.abort);
let turn_events_clone = Arc::clone(&params.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(&params.messages, &params.abort, &params.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(
&params.turn_events,
TurnEvent::StreamDone(assistant_msg.clone()),
);
if let Some((tokens_in, tokens_out)) = usage {
Self::push_event(
&params.turn_events,
TurnEvent::Usage {
tokens_in,
tokens_out,
},
);
}
emit_usage(&params.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<P: ProviderService, T: ToolExecutor> 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(
&params.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(), &params.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(
&params.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<ChatMessage> = params.messages.drain(original_count - 1..).collect();
push_event(
&params.turn_events,
TurnEvent::Compacted(params.messages.clone()),
TurnEvent::Compacted(compacted),
);
Self::push_event(&params.turn_events, TurnEvent::Done);
Self::mark_done(&params.in_flight);
push_event(&params.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<P: ProviderService>(
messages: &mut Vec<ChatMessage>,
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<P: ProviderService>(
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(())
}
}
}
+39 -22
View File
@@ -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(),
+81
View File
@@ -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<String>,
/// 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<String>,
agent_name: impl Into<String>,
current_tool: Option<String>,
) -> 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<String>, agent_name: impl Into<String>) -> 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<String>, agent_name: impl Into<String>) -> 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<String>,
agent_name: impl Into<String>,
error: String,
) -> Self {
AgentProgress {
agent_id: agent_id.into(),
agent_name: agent_name.into(),
status: AgentStatus::Failed(error),
current_tool: None,
steps: None,
}
}
}
+92
View File
@@ -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"));
}
}
+5
View File
@@ -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::*;
+72 -32
View File
@@ -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})"))
}
+109
View File
@@ -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<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<crate::tools::GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
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<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<crate::tools::GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
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<PathBuf>) -> 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<Mutex<std::collections::VecDeque<crate::TurnEvent>>>,
) -> Self {
self.turn_events = Some(v);
self
}
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> 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,
}
}
}
@@ -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<String> {
let mut matches = Vec::new();
for check in checks {
if path.contains(&check.pattern) || content.contains(&check.rule) {
matches.push(check.name.clone());
}
}
matches
}
+43 -350
View File
@@ -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<String>;
}
/// 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<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
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<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub dir_cache: Arc<tokio::sync::RwLock<crate::DirCache>>,
pub mention_index: crate::MentionIndex,
pub origin: crate::Origin,
pub graduated_checks: Vec<GraduatedCheck>,
pub lsp_manager: Arc<Mutex<crate::lsp::manager::LspManager>>,
pub turn_events:
Option<Arc<Mutex<std::collections::VecDeque<crate::TurnEvent>>>>,
pub workflow_findings: Option<Arc<Mutex<Vec<String>>>>,
pub abort_flag: Option<Arc<AtomicBool>>,
}
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<PathBuf>) -> 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<Mutex<std::collections::VecDeque<crate::TurnEvent>>>,
) -> Self {
self.turn_events = Some(v);
self
}
pub fn workflow_findings(mut self, v: Option<Arc<Mutex<Vec<String>>>>) -> 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<String> {
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<Box<dyn Tool>> {
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<String> {
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<String> {
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<PathBuf> {
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<dyn Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
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;
+70
View File
@@ -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<Box<dyn super::Tool>> {
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<dyn super::Tool>]) -> Vec<zesdex_domain::core::ToolDef> {
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()
}
+156
View File
@@ -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<String> {
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<String> {
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<PathBuf> {
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");
}
}
-490
View File
@@ -1,490 +0,0 @@
//! The `Action` enum — a single well-typed event in the TUI, produced by
//! key input and applied to `AppStateRest` by the event loop.
//!
//! # Flow
//! `controller::input::handle_key` returns `Vec<Action>` → the event loop
//! calls `apply_action(&mut state, action)` for each one → state is mutated
//! in place.
//!
//! # Design
//! Every state mutation funnels through this single chokepoint so the view
//! layer never mutates state directly and the controller never needs to know
//! *how* state is updated — only *what* action to produce.
use crate::state::Overlay;
use std::process::Command;
use tracing::debug;
/// A single well-typed event in the TUI that mutates `AppStateRest`.
#[derive(Debug, Clone)]
pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight,
/// Navigate up through command history.
HistoryUp,
/// Navigate down through command history.
HistoryDown,
/// Scroll the transcript pane up.
ScrollUp,
/// Scroll the transcript pane down.
ScrollDown,
/// Open a named overlay.
OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote {
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
kind: String,
/// The message text to display.
message: String,
},
/// Show the quit-confirmation overlay.
QuitConfirm,
/// Terminal resize event.
Resize(u16, u16),
/// Periodic timer tick — drains queued `TurnEvent`s.
Tick,
/// Accept a lesson (learned behaviour pattern) by name.
LessonAccept {
name: String,
},
/// Reject a lesson by name.
LessonReject {
name: String,
},
/// Delete a previously stored lesson by name.
LessonDelete {
name: String,
},
/// Start the OAuth device-code login flow for a named provider.
StartOAuth {
provider: String,
},
/// Open the inline file editor for `path`.
OpenEditor {
path: String,
},
/// Register a new MCP server by name and shell command.
McpAdd {
name: String,
command: String,
},
/// Open the model-picker overlay.
ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
/// Show the git diff preview overlay.
ShowDiff,
/// Scroll the diff overlay.
DiffScroll(i32),
}
/// Apply an `Action` to `AppStateRest`.
///
/// Flow: pattern-match the variant → mutate state in place.
/// This is the single chokepoint for all state mutations.
///
/// Return: nothing; `state` is mutated in place.
#[tracing::instrument(skip(state))]
pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
debug!("apply_action: {:?}", action);
match action {
Action::ForceQuit => {
state.quit = true;
}
Action::QuitConfirm => {
state.misc.overlay = crate::state::Overlay::QuitConfirm;
state.mark_dirty();
}
Action::Resize(w, _h) => {
// Invalidate display cache so pre_render_chat rebuilds at new width.
if state.last_render_width != w {
state.transcript_cache.dirty = true;
state.last_render_width = w;
}
state.mark_dirty();
}
Action::Tick => {
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
// Drain turn events from the shared queue — collect events first,
// then mutate state, to avoid borrow conflicts with the mutex guard.
let events: Vec<zesdex_infrastructure::TurnEvent> = state
.turn_events
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
let had_events = !events.is_empty();
for event in events {
match event {
zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => {
if kind == "hive_mind_converged" {
if let Some(ref mut rt) = state.session_runtime {
rt.hive_mind_converged = true;
}
} else {
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
message,
));
}
}
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
msg.role,
msg.content.unwrap_or_default(),
));
}
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::Tool,
output,
));
}
zesdex_infrastructure::TurnEvent::Usage { tokens_in, tokens_out } => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
rt.usage.last_tokens_in = tokens_in;
rt.usage.last_tokens_out = tokens_out;
rt.usage.api_calls = rt.usage.api_calls.saturating_add(1);
}
}
zesdex_infrastructure::TurnEvent::Error(msg) => {
state.toast_error(msg);
}
zesdex_infrastructure::TurnEvent::StreamStart => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::Assistant,
String::new(),
));
}
zesdex_infrastructure::TurnEvent::StreamToken(text) => {
state.append_to_last_transcript(&text, false);
}
zesdex_infrastructure::TurnEvent::StreamReasoning(text) => {
state.append_to_last_transcript(&text, true);
}
zesdex_infrastructure::TurnEvent::StreamDone(_msg) => {
// The final message is already accumulated in the transcript.
// We might want to trigger a save or something here, but for UI, we just mark dirty.
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
if let Some(ref mut rt) = state.session_runtime {
rt.messages = msgs;
}
}
zesdex_infrastructure::TurnEvent::TodoUpdate(content) => {
state.misc.todo_content = content;
state.toast_success("TODO list updated.");
}
zesdex_infrastructure::TurnEvent::PlanUpdate(content) => {
state.misc.plan_content = content;
state.toast_success("Project plan updated.");
}
zesdex_infrastructure::TurnEvent::Done => {
state.turn_in_flight_flag.store(false, std::sync::atomic::Ordering::SeqCst);
// Mark dirty so spinner disappears
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status,
} => {
// Find existing agent by ID, or create new one
let idx = state
.workflow_engine
.agents
.iter()
.position(|a| a.name == agent_id);
match status {
zesdex_infrastructure::AgentStatus::Pending => {
if idx.is_none() {
state.workflow_engine.agents.push(
crate::state::SimpleAgent::with_display(
agent_id,
agent_name,
),
);
}
}
zesdex_infrastructure::AgentStatus::Running => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state =
crate::state::AgentState::Running;
state.workflow_engine.agents[i].display_name =
agent_name;
state.workflow_engine.agents[i].started_at =
Some(chrono::Utc::now().timestamp_millis());
} else {
let mut agent =
crate::state::SimpleAgent::with_display(
agent_id,
agent_name,
);
agent.state = crate::state::AgentState::Running;
agent.started_at =
Some(chrono::Utc::now().timestamp_millis());
state.workflow_engine.agents.push(agent);
}
}
zesdex_infrastructure::AgentStatus::Completed => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state =
crate::state::AgentState::Completed;
state.workflow_engine.agents[i].display_name =
agent_name;
state.workflow_engine.agents[i].completed_at =
Some(chrono::Utc::now().timestamp_millis());
}
}
zesdex_infrastructure::AgentStatus::Failed(msg) => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state =
crate::state::AgentState::Failed;
state.workflow_engine.agents[i].display_name =
agent_name;
state.workflow_engine.agents[i].error = Some(msg);
}
}
zesdex_infrastructure::AgentStatus::Cancelled => {
if let Some(i) = idx {
state.workflow_engine.agents.remove(i);
}
}
}
state.dirty = true;
}
_ => {
debug!("unhandled turn event variant");
state.dirty = true;
}
}
}
// Mark dirty only when there's something that changed:
// - new events were processed (messages, errors, etc.)
// - turn is in-flight (spinner needs to animate each tick)
// When idle with no events, skip the dirty flag to avoid useless renders.
if had_events || state.turn_in_flight() {
state.mark_dirty();
}
}
Action::SubmitInput(text) => {
// Push user message to transcript display
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::User,
text.clone(),
));
state.input.submit();
// Spawn real agent turn on a background thread
crate::turn::spawn_agent_turn(state, text);
// Invalidate token count cache since we added a message
state.token_count_dirty = true;
state.mark_dirty();
}
Action::DeleteChar => {
state.input.delete_left();
state.mark_dirty();
}
Action::DeleteCharRight => {
state.input.delete_right();
state.mark_dirty();
}
Action::CursorLeft => {
state.input.cursor = state.input.cursor.saturating_sub(1);
state.mark_dirty();
}
Action::CursorRight => {
if state.input.cursor < state.input.buffer.len() {
state.input.cursor += 1;
}
state.mark_dirty();
}
Action::HistoryUp => {
state.input.history_up();
state.mark_dirty();
}
Action::HistoryDown => {
state.input.history_down();
state.mark_dirty();
}
Action::ScrollUp => {
state.scroll.scroll_up(3);
state.mark_dirty();
}
Action::ScrollDown => {
state.scroll.scroll_down(3);
state.mark_dirty();
}
Action::OpenOverlay(overlay) => {
state.misc.overlay = overlay;
state.mark_dirty();
}
Action::CloseOverlay => {
state.misc.overlay = crate::state::Overlay::None;
state.mark_dirty();
}
Action::SystemNote { kind: _, message } => {
state.push_transcript(crate::state::ChatMessageDisplay::new(
zesdex_domain::core::Role::System,
message,
));
}
Action::LessonAccept { name } => {
state.toast_info(format!("Lesson accepted: {name}"));
}
Action::LessonReject { name } => {
state.toast_info(format!("Lesson rejected: {name}"));
}
Action::LessonDelete { name } => {
state.toast_info(format!("Lesson deleted: {name}"));
}
Action::StartOAuth { provider } => {
state.toast_info(format!("OAuth login started for {provider}"));
}
Action::OpenEditor { path } => {
let content = std::fs::read_to_string(&path).unwrap_or_default();
state.misc.editor = Some(crate::state::EditorState::new(
std::path::PathBuf::from(&path),
content,
));
state.misc.overlay = crate::state::Overlay::Editor;
state.mark_dirty();
}
Action::McpAdd { name, command } => {
state.toast_info(format!("MCP server added: {name} ({command})"));
}
Action::ModelList => {
state.misc.overlay = crate::state::Overlay::ModelSelector;
state.mark_dirty();
}
Action::AbortTurn => {
state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
state.toast_info("Aborting current turn...".to_string());
}
Action::Compact => {
state.toast_info("Compacting conversation...".to_string());
}
Action::ShowDiff => {
// Run git diff to get current changes
let diff_output = git_diff_output(state);
state.misc.diff_content = diff_output;
state.misc.diff_scroll = 0;
state.misc.overlay = crate::state::Overlay::Diff;
state.mark_dirty();
}
Action::DiffScroll(amount) => {
let max_scroll = state
.misc
.diff_content
.lines()
.count()
.saturating_sub(1);
let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize;
state.misc.diff_scroll = new_scroll.min(max_scroll);
state.mark_dirty();
}
}
}
/// Run `git diff` and return the output for display in the diff overlay.
///
/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back
/// to `git diff` if HEAD has no commits yet. Returns a summary of what
/// changed with colored +/- markers.
fn git_diff_output(state: &crate::state::AppStateRest) -> String {
let root = state
.workspace_roots
.first()
.cloned()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let mut output = String::new();
// Try diff against HEAD
let head_result = Command::new("git")
.args(["diff", "HEAD"])
.current_dir(&root)
.output();
match head_result {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Changes (against HEAD) ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
Err(_) => {
// Fallback: no HEAD yet (new repo)
let diff_result = Command::new("git")
.args(["diff"])
.current_dir(&root)
.output();
if let Ok(out) = diff_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Unstaged Changes ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
}
}
// Get staged changes too
let staged_result = Command::new("git")
.args(["diff", "--cached"])
.current_dir(&root)
.output();
if let Ok(out) = staged_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Staged Changes ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
// Also get status summary
let status_result = Command::new("git")
.args(["status", "--short"])
.current_dir(&root)
.output();
if let Ok(out) = status_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Summary ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
if output.is_empty() {
output = "No changes detected in the working tree.".to_string();
}
output
}
+88
View File
@@ -0,0 +1,88 @@
//! Git diff output helper for the diff overlay.
//!
//! Extracted to its own module so the action dispatcher stays focused on
//! state mutation logic rather than shell-out I/O.
use std::process::Command;
/// Run `git diff` and return the output for display in the diff overlay.
///
/// Flow: runs `git diff HEAD` (staged + unstaged changes), and falls back
/// to `git diff` if HEAD has no commits yet. Returns a summary of what
/// changed with coloured +/- markers.
pub fn git_diff_output(workspace_roots: &[std::path::PathBuf]) -> String {
let root = workspace_roots
.first()
.cloned()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let mut output = String::new();
// Try diff against HEAD
let head_result = Command::new("git")
.args(["diff", "HEAD"])
.current_dir(&root)
.output();
match head_result {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Changes (against HEAD) ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
Err(_) => {
// Fallback: no HEAD yet (new repo)
let diff_result = Command::new("git")
.args(["diff"])
.current_dir(&root)
.output();
if let Ok(out) = diff_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Unstaged Changes ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
}
}
// Get staged changes too
let staged_result = Command::new("git")
.args(["diff", "--cached"])
.current_dir(&root)
.output();
if let Ok(out) = staged_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Staged Changes ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
// Also get status summary
let status_result = Command::new("git")
.args(["status", "--short"])
.current_dir(&root)
.output();
if let Ok(out) = status_result {
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
output.push_str("=== Summary ===\n");
output.push_str(&stdout);
output.push('\n');
}
}
if output.is_empty() {
output = "No changes detected in the working tree.".to_string();
}
output
}
+192
View File
@@ -0,0 +1,192 @@
//! Extracted action handlers for complex `Action` variants.
//!
//! The `apply_action` dispatcher delegates to these functions for variants
//! whose logic exceeds a few lines, keeping the match statement compact
//! and readable.
use crate::state::{AppStateRest, ChatMessageDisplay};
use tracing::debug;
use zesdex_domain::core::Role;
// ---------------------------------------------------------------------------
// Tick handler — drains turn events from the shared queue
// ---------------------------------------------------------------------------
/// Process the `Action::Tick` event: increment tick counter, drain all
/// queued `TurnEvent` values from the shared queue, and apply each one
/// to the application state.
pub fn handle_tick(state: &mut AppStateRest) {
state.misc.tick_count = state.misc.tick_count.wrapping_add(1);
// Drain turn events — collect into a local Vec first to avoid holding
// the mutex guard across the entire dispatch loop.
let events: Vec<zesdex_infrastructure::TurnEvent> = state
.turn_events
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
let had_events = !events.is_empty();
for event in events {
apply_turn_event(state, event);
}
// Mark dirty when there were events OR a turn is in-flight (spinner
// animation); skip the flag when idle to avoid useless re-renders.
if had_events || state.turn_in_flight() {
state.mark_dirty();
}
}
// ---------------------------------------------------------------------------
// TurnEvent dispatcher
// ---------------------------------------------------------------------------
/// Apply a single `TurnEvent` to `AppStateRest`. Extracted from the
/// `Tick` handler so each event variant's logic is self-contained.
fn apply_turn_event(state: &mut AppStateRest, event: zesdex_infrastructure::TurnEvent) {
match event {
zesdex_infrastructure::TurnEvent::SystemNote { kind, message } => {
if kind == "hive_mind_converged" {
if let Some(ref mut rt) = state.session_runtime {
rt.hive_mind_converged = true;
}
} else {
state
.push_transcript(ChatMessageDisplay::new(Role::System, message));
}
}
zesdex_infrastructure::TurnEvent::AssistantMessage(msg) => {
state.push_transcript(ChatMessageDisplay::new(
msg.role,
msg.content.unwrap_or_default(),
));
}
zesdex_infrastructure::TurnEvent::ToolResult { output, .. } => {
state.push_transcript(ChatMessageDisplay::new(Role::Tool, output));
}
zesdex_infrastructure::TurnEvent::Usage {
tokens_in,
tokens_out,
} => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
rt.usage.last_tokens_in = tokens_in;
rt.usage.last_tokens_out = tokens_out;
rt.usage.api_calls = rt.usage.api_calls.saturating_add(1);
}
}
zesdex_infrastructure::TurnEvent::Error(msg) => {
state.toast_error(msg);
}
zesdex_infrastructure::TurnEvent::StreamStart => {
state
.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new()));
}
zesdex_infrastructure::TurnEvent::StreamToken(text) => {
state.append_to_last_transcript(&text, false);
}
zesdex_infrastructure::TurnEvent::StreamReasoning(text) => {
state.append_to_last_transcript(&text, true);
}
zesdex_infrastructure::TurnEvent::StreamDone(_msg) => {
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
if let Some(ref mut rt) = state.session_runtime {
rt.messages = msgs;
}
}
zesdex_infrastructure::TurnEvent::TodoUpdate(content) => {
state.misc.todo_content = content;
state.toast_success("TODO list updated.");
}
zesdex_infrastructure::TurnEvent::PlanUpdate(content) => {
state.misc.plan_content = content;
state.toast_success("Project plan updated.");
}
zesdex_infrastructure::TurnEvent::Done => {
state
.turn_in_flight_flag
.store(false, std::sync::atomic::Ordering::SeqCst);
state.dirty = true;
}
zesdex_infrastructure::TurnEvent::WorkflowAgentUpdate {
agent_id,
agent_name,
status,
} => {
apply_workflow_update(state, agent_id, agent_name, status);
}
_ => {
debug!("unhandled turn event variant");
state.dirty = true;
}
}
}
// ---------------------------------------------------------------------------
// Workflow-agent update handler
// ---------------------------------------------------------------------------
/// Update the workflow sidebar state from a `WorkflowAgentUpdate` event.
fn apply_workflow_update(
state: &mut AppStateRest,
agent_id: String,
agent_name: String,
status: zesdex_infrastructure::AgentStatus,
) {
let idx = state
.workflow_engine
.agents
.iter()
.position(|a| a.name == agent_id);
match status {
zesdex_infrastructure::AgentStatus::Pending => {
if idx.is_none() {
state
.workflow_engine
.agents
.push(crate::state::SimpleAgent::with_display(agent_id, agent_name));
}
}
zesdex_infrastructure::AgentStatus::Running => {
let now = chrono::Utc::now().timestamp_millis();
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Running;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].started_at = Some(now);
} else {
let mut agent =
crate::state::SimpleAgent::with_display(agent_id, agent_name);
agent.state = crate::state::AgentState::Running;
agent.started_at = Some(now);
state.workflow_engine.agents.push(agent);
}
}
zesdex_infrastructure::AgentStatus::Completed => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Completed;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].completed_at =
Some(chrono::Utc::now().timestamp_millis());
}
}
zesdex_infrastructure::AgentStatus::Failed(msg) => {
if let Some(i) = idx {
state.workflow_engine.agents[i].state = crate::state::AgentState::Failed;
state.workflow_engine.agents[i].display_name = agent_name;
state.workflow_engine.agents[i].error = Some(msg);
}
}
zesdex_infrastructure::AgentStatus::Cancelled => {
if let Some(i) = idx {
state.workflow_engine.agents.remove(i);
}
}
}
state.dirty = true;
}
+240
View File
@@ -0,0 +1,240 @@
//! The `Action` enum — a single well-typed event in the TUI, produced by
//! key input and applied to `AppStateRest` by the event loop.
//!
//! # Flow
//! `controller::input::handle_key` returns `Vec<Action>` \u{2192} the event loop
//! calls `apply_action(&mut state, action)` for each one \u{2192} state is mutated
//! in place.
//!
//! # Organisation
//!
//! ```text
//! action/
//! ├── mod.rs — Action enum + apply_action dispatcher
//! ├── handlers.rs — Extracted handlers for complex action variants
//! └── git.rs — git diff output helper (shell-out I/O)
//! ```
//!
//! # Design
//! Every state mutation funnels through this single chokepoint so the view
//! layer never mutates state directly and the controller never needs to know
//! *how* state is updated \u{2014} only *what* action to produce.
mod git;
mod handlers;
use tracing::debug;
use crate::state::Overlay;
use crate::state::{AppStateRest, ChatMessageDisplay};
use zesdex_domain::core::Role;
/// A single well-typed event in the TUI that mutates `AppStateRest`.
#[derive(Debug, Clone)]
pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight,
/// Navigate up through command history.
HistoryUp,
/// Navigate down through command history.
HistoryDown,
/// Scroll the transcript pane up.
ScrollUp,
/// Scroll the transcript pane down.
ScrollDown,
/// Open a named overlay.
OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote {
/// Note category: "error", "info", "clear", "hive_mind_converged", etc.
kind: String,
/// The message text to display.
message: String,
},
/// Show the quit-confirmation overlay.
QuitConfirm,
/// Terminal resize event.
Resize(u16, u16),
/// Periodic timer tick — drains queued `TurnEvent`s.
Tick,
/// Accept a lesson (learned behaviour pattern) by name.
LessonAccept { name: String },
/// Reject a lesson by name.
LessonReject { name: String },
/// Delete a previously stored lesson by name.
LessonDelete { name: String },
/// Start the OAuth device-code login flow for a named provider.
StartOAuth { provider: String },
/// Open the inline file editor for `path`.
OpenEditor { path: String },
/// Register a new MCP server by name and shell command.
McpAdd { name: String, command: String },
/// Open the model-picker overlay.
ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
/// Show the git diff preview overlay.
ShowDiff,
/// Scroll the diff overlay by `amount` lines (negative = up, positive = down).
DiffScroll(i32),
}
/// Apply an `Action` to `AppStateRest`.
///
/// Flow: pattern-match the variant \u{2192} mutate state in place.
/// This is the single chokepoint for all state mutations.
///
/// Return: nothing; `state` is mutated in place.
#[tracing::instrument(skip(state))]
pub fn apply_action(state: &mut AppStateRest, action: Action) {
debug!("apply_action: {:?}", action);
match action {
// ── Lifecycle ─────────────────────────────────────────────────
Action::ForceQuit => state.quit = true,
Action::QuitConfirm => {
state.misc.overlay = Overlay::QuitConfirm;
state.mark_dirty();
}
Action::Resize(w, _h) => {
if state.last_render_width != w {
state.transcript_cache.dirty = true;
state.last_render_width = w;
}
state.mark_dirty();
}
// ── Timer tick (delegates to extracted handler) ────────────────
Action::Tick => handlers::handle_tick(state),
// ── Input ──────────────────────────────────────────────────────
Action::SubmitInput(text) => {
state
.push_transcript(ChatMessageDisplay::new(Role::User, text.clone()));
state.input.submit();
crate::turn::spawn_agent_turn(state, text);
state.token_count_dirty = true;
state.mark_dirty();
}
Action::DeleteChar => {
state.input.delete_left();
state.mark_dirty();
}
Action::DeleteCharRight => {
state.input.delete_right();
state.mark_dirty();
}
Action::CursorLeft => {
state.input.cursor = state.input.cursor.saturating_sub(1);
state.mark_dirty();
}
Action::CursorRight => {
if state.input.cursor < state.input.buffer.len() {
state.input.cursor += 1;
}
state.mark_dirty();
}
Action::HistoryUp => {
state.input.history_up();
state.mark_dirty();
}
Action::HistoryDown => {
state.input.history_down();
state.mark_dirty();
}
// ── Scroll ─────────────────────────────────────────────────────
Action::ScrollUp => {
state.scroll.scroll_up(3);
state.mark_dirty();
}
Action::ScrollDown => {
state.scroll.scroll_down(3);
state.mark_dirty();
}
// ── Overlay ────────────────────────────────────────────────────
Action::OpenOverlay(overlay) => {
state.misc.overlay = overlay;
state.mark_dirty();
}
Action::CloseOverlay => {
state.misc.overlay = Overlay::None;
state.mark_dirty();
}
Action::SystemNote { kind: _, message } => {
state
.push_transcript(ChatMessageDisplay::new(Role::System, message));
}
Action::ModelList => {
state.misc.overlay = Overlay::ModelSelector;
state.mark_dirty();
}
Action::OpenEditor { path } => {
let content = std::fs::read_to_string(&path).unwrap_or_default();
state.misc.editor = Some(crate::state::EditorState::new(
std::path::PathBuf::from(&path),
content,
));
state.misc.overlay = Overlay::Editor;
state.mark_dirty();
}
// ── Lessons ────────────────────────────────────────────────────
Action::LessonAccept { name } => state.toast_info(format!("Lesson accepted: {name}")),
Action::LessonReject { name } => state.toast_info(format!("Lesson rejected: {name}")),
Action::LessonDelete { name } => state.toast_info(format!("Lesson deleted: {name}")),
// ── Auth ───────────────────────────────────────────────────────
Action::StartOAuth { provider } => {
state.toast_info(format!("OAuth login started for {provider}"))
}
// ── MCP ────────────────────────────────────────────────────────
Action::McpAdd { name, command } => {
state.toast_info(format!("MCP server added: {name} ({command})"))
}
// ── Turn control ────────────────────────────────────────────────
Action::AbortTurn => {
state
.abort_flag
.store(true, std::sync::atomic::Ordering::SeqCst);
state.toast_info("Aborting current turn...".to_string());
}
Action::Compact => state.toast_info("Compacting conversation...".to_string()),
// ── Diff ───────────────────────────────────────────────────────
Action::ShowDiff => {
let diff_output = git::git_diff_output(&state.workspace_roots);
state.misc.diff_content = diff_output;
state.misc.diff_scroll = 0;
state.misc.overlay = Overlay::Diff;
state.mark_dirty();
}
Action::DiffScroll(amount) => {
let max_scroll = state
.misc
.diff_content
.lines()
.count()
.saturating_sub(1);
let new_scroll = (state.misc.diff_scroll as i32 + amount).max(0) as usize;
state.misc.diff_scroll = new_scroll.min(max_scroll);
state.mark_dirty();
}
}
}
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
//! Standalone helper functions that operate on AppStateRest.
//!
//! These are kept separate from the AppStateRest impl block to keep that
//! file focused on state definition and direct mutations. Helpers that
//! aggregate, compute, or read from the filesystem live here.
use crate::state::AppStateRest;
// ---------------------------------------------------------------------------
// Effort levels
// ---------------------------------------------------------------------------
/// Name of each reasoning-effort tier.
pub const EFFORT_LEVELS: &[&str] = &[
"Auto \u{2014} let the provider decide",
"Low \u{2014} fast, minimal reasoning",
"Medium \u{2014} balanced speed & reasoning",
"High \u{2014} thorough reasoning",
"Maximum \u{2014} deep analysis",
];
/// Return the current effort index from state.
pub fn current_effort(state: &AppStateRest) -> usize {
state
.misc
.effort_level
.saturating_sub(1)
.min(EFFORT_LEVELS.len().saturating_sub(1))
}
/// Cycle effort level up or down.
pub fn cycle_effort(state: &mut AppStateRest, forward: bool) {
let n = EFFORT_LEVELS.len();
if forward {
state.misc.effort_level = (state.misc.effort_level % n) + 1;
} else {
state.misc.effort_level = if state.misc.effort_level <= 1 {
n
} else {
state.misc.effort_level - 1
};
}
state.mark_dirty();
}
// ---------------------------------------------------------------------------
// Learning items
// ---------------------------------------------------------------------------
/// A lesson entry displayed in the Learning overlay.
#[derive(Debug, Clone)]
pub enum LearningItem {
/// A newly-generated lesson pending user approval.
Pending {
name: String,
content: String,
scope: String,
confidence: f64,
},
/// A lesson that has been accepted and stored.
Stored {
name: String,
content: String,
lifecycle: String,
scope: String,
description: String,
},
}
/// Read lesson markdown files from the `lessons/` subdirectory under the
/// memory directory.
#[tracing::instrument(skip(state))]
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
let lessons_dir = state.memory_dir.join("lessons");
if !lessons_dir.exists() {
return Vec::new();
}
let mut items = Vec::new();
if let Ok(entries) = std::fs::read_dir(&lessons_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
if let Ok(content) = std::fs::read_to_string(&path) {
items.push(LearningItem::Stored {
name: path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
content,
lifecycle: "filesystem".to_string(),
scope: "filesystem".to_string(),
description: String::new(),
});
}
}
}
}
items
}
// ---------------------------------------------------------------------------
// Generic helpers
// ---------------------------------------------------------------------------
/// Cycle the selected index within bounds.
pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize {
if n == 0 {
return 0;
}
if forward {
(current + 1) % n
} else if current == 0 {
n - 1
} else {
current - 1
}
}
/// Return the number of rewind points available.
pub fn rewind_count(state: &AppStateRest) -> usize {
state.transcript_cache.messages.len()
}
// ---------------------------------------------------------------------------
// Token counting
// ---------------------------------------------------------------------------
/// Resolve the window size for context window management.
///
/// Uses the model name from settings to determine max context window,
/// falling back to settings-configured max or 128k default.
#[tracing::instrument(skip(_app_config, settings))]
pub fn resolve_context_window(
_app_config: &zesdex_domain::cms::AppConfig,
settings: &zesdex_domain::cms::Settings,
) -> usize {
if let Some(max) = settings.max_tokens {
if max > 0 {
return max as usize;
}
}
256_000
}
/// Count tokens using tiktoken, fall back to character estimation.
///
/// Flow: try tiktoken-rs `cl100k_base` BPE encoding \u{2192} return accurate count.
/// On failure (~4 chars per token heuristic), fall back to character-based
/// estimation so the UI never blocks on an unavailable tokeniser.
#[tracing::instrument]
pub fn count_tokens(text: &str) -> usize {
if let Ok(bpe) = tiktoken_rs::cl100k_base() {
return bpe.encode_with_special_tokens(text).len();
}
// Fallback: ~4 chars per token
text.len().div_ceil(4)
}
+270
View File
@@ -0,0 +1,270 @@
//! Chat input buffer, cursor, history, and autocomplete state.
use std::path::PathBuf;
/// Which source populated the autocomplete dropdown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteKind {
/// Builtin slash-command (e.g. `/model`, `/help`).
Command,
/// `@file` mention from the workspace file index.
FileMention,
}
/// Builtin slash-commands recognised by the chat input autocomplete.
const COMMANDS: &[&str] = &[
"/help", "/quit", "/clear", "/login", "/login zen", "/login openai",
"/edit", "/mcp add", "/model", "/model ls", "/model add",
"/todo", "/usage", "/compact",
];
/// The user's input buffer, cursor position, history, and autocomplete
/// state for the chat prompt.
#[derive(Debug, Clone)]
pub struct InputState {
/// Raw UTF-8 input buffer content.
pub buffer: String,
/// Byte offset of the cursor within `buffer`.
pub cursor: usize,
/// Previously submitted input lines, oldest-first.
pub history: Vec<String>,
/// Index into `history` when browsing (None = at the current input).
pub history_idx: Option<usize>,
/// Current autocomplete candidate list.
pub autocomplete_candidates: Vec<String>,
/// Focused index within `autocomplete_candidates`.
pub autocomplete_idx: usize,
/// Whether the autocomplete dropdown is visible.
pub autocomplete_visible: bool,
/// Which kind of autocomplete is active.
pub autocomplete_kind: AutocompleteKind,
/// Byte offset of the `@` character that triggered file mention autocomplete.
pub mention_start: usize,
/// Optional path to a persistent history file.
pub history_file: Option<PathBuf>,
}
impl InputState {
/// Create an empty input state.
pub fn new() -> Self {
InputState {
buffer: String::new(),
cursor: 0,
history: Vec::new(),
history_idx: None,
autocomplete_candidates: Vec::new(),
autocomplete_idx: 0,
autocomplete_visible: false,
autocomplete_kind: AutocompleteKind::Command,
mention_start: 0,
history_file: None,
}
}
/// Hide the autocomplete dropdown and clear its state.
pub fn close_autocomplete(&mut self) {
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.autocomplete_idx = 0;
self.autocomplete_kind = AutocompleteKind::Command;
self.mention_start = 0;
}
/// Open or refresh the autocomplete dropdown by filtering `COMMANDS`.
pub fn open_autocomplete(&mut self) {
let trimmed = self.buffer.trim().to_string();
if trimmed.is_empty() || !trimmed.starts_with('/') {
self.close_autocomplete();
return;
}
let prefix = trimmed.to_lowercase();
self.autocomplete_candidates = COMMANDS
.iter()
.filter(|c| c.starts_with(&prefix))
.map(std::string::ToString::to_string)
.collect();
self.autocomplete_kind = AutocompleteKind::Command;
self.autocomplete_idx = 0;
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
}
/// Find the `@mention` token (if any) immediately before the cursor.
pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> {
let before_cursor = &self.buffer[..self.cursor];
let at_pos = before_cursor.rfind('@')?;
let between = &before_cursor[at_pos + 1..];
if between.chars().any(char::is_whitespace) {
return None;
}
let boundary_ok = at_pos == 0
|| before_cursor[..at_pos]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
if !boundary_ok {
return None;
}
Some((at_pos, between.to_string()))
}
/// Open or refresh the `@file` mention dropdown from `files`.
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
let Some((start, query)) = self.mention_query_at_cursor() else {
self.close_autocomplete();
return;
};
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
let matched_files = pattern.match_list(files.iter(), &mut matcher);
self.autocomplete_candidates = matched_files
.into_iter()
.take(10)
.map(|(f, _)| f.clone())
.collect();
self.autocomplete_kind = AutocompleteKind::FileMention;
self.mention_start = start;
self.autocomplete_idx = 0;
self.autocomplete_visible = !self.autocomplete_candidates.is_empty();
}
/// Move the autocomplete selection up (forward=false) or down (forward=true).
pub fn cycle_autocomplete(&mut self, forward: bool) {
let n = self.autocomplete_candidates.len();
if n == 0 {
return;
}
if forward {
self.autocomplete_idx = (self.autocomplete_idx + 1) % n;
} else {
self.autocomplete_idx = if self.autocomplete_idx == 0 {
n - 1
} else {
self.autocomplete_idx - 1
};
}
}
/// Accept the currently selected autocomplete candidate.
pub fn select_autocomplete(&mut self) -> bool {
let Some(candidate) = self
.autocomplete_candidates
.get(self.autocomplete_idx)
.cloned()
else {
return false;
};
match self.autocomplete_kind {
AutocompleteKind::Command => {
self.buffer = candidate;
self.cursor = self.buffer.len();
}
AutocompleteKind::FileMention => {
if self.cursor < self.mention_start || self.mention_start > self.buffer.len() {
self.close_autocomplete();
return false;
}
let replacement = format!("@{candidate} ");
self.buffer
.replace_range(self.mention_start..self.cursor, &replacement);
self.cursor = self.mention_start + replacement.len();
}
}
self.close_autocomplete();
true
}
/// Tab-complete: open dropdown or cycle forward.
pub fn tab_complete(&mut self) {
if self.autocomplete_visible {
self.cycle_autocomplete(true);
} else {
self.open_autocomplete();
}
}
/// Insert a character at the cursor position.
pub fn insert(&mut self, c: char) {
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
/// Delete the character to the left of the cursor (backspace).
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.buffer.remove(self.cursor);
}
}
/// Delete the character at the cursor position (forward delete).
pub fn delete_right(&mut self) {
if self.cursor < self.buffer.len() {
self.buffer.remove(self.cursor);
}
}
/// Submit the current buffer and return the submitted text.
pub fn submit(&mut self) -> String {
let result = self.buffer.clone();
if !result.is_empty() {
if self.history.last() != Some(&result) {
self.history.push(result.clone());
if let Some(ref path) = self.history_file {
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
use std::io::Write;
let _ = writeln!(file, "{result}");
}
}
}
self.history_idx = None;
}
self.buffer.clear();
self.cursor = 0;
result
}
/// Navigate backward through input history.
pub fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
let idx = match self.history_idx {
Some(i) if i > 0 => i - 1,
None => self.history.len() - 1,
Some(_) => return,
};
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
}
/// Navigate forward through input history.
pub fn history_down(&mut self) {
match self.history_idx {
Some(i) if i < self.history.len() - 1 => {
let idx = i + 1;
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
}
Some(_) => {
self.history_idx = None;
self.buffer.clear();
self.cursor = 0;
}
None => {}
}
}
}
impl Default for InputState {
fn default() -> Self {
Self::new()
}
}
+195
View File
@@ -0,0 +1,195 @@
//! Miscellaneous state: overlay enum, misc state bag, editor state.
use std::path::PathBuf;
use zesdex_infrastructure::Toast;
/// Which modal overlay, if any, is currently shown over the main TUI view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
/// No overlay; the main chat view is shown.
None,
/// Key bindings help screen.
Help,
/// Settings/configuration panel.
Settings,
/// Background bash job viewer.
Bash,
/// "Are you sure you want to quit?" confirmation.
QuitConfirm,
/// Raw key-code input capture (for binding custom keys).
KeyInput,
/// Inline editor (opened via `/edit`).
Editor,
/// Reasoning effort level selector.
Effort,
/// MCP server management panel.
Mcp,
/// Task list overlay.
Todo,
/// Project plan overlay.
Plan,
/// Session rewind / history scrubber.
Rewind,
/// Learning / lesson management panel.
Learning,
/// Token usage statistics panel.
Usage,
/// Generic loading spinner overlay.
Loading,
/// Model selector dropdown.
ModelSelector,
/// "Clear conversation?" confirmation.
ClearConfirm,
/// Git diff preview overlay.
Diff,
}
impl Overlay {
/// Human-readable name for this overlay variant.
pub fn as_str(&self) -> &'static str {
match self {
Overlay::None => "none",
Overlay::Help => "help",
Overlay::Settings => "settings",
Overlay::Bash => "bash",
Overlay::QuitConfirm => "quit_confirm",
Overlay::KeyInput => "key_input",
Overlay::Editor => "editor",
Overlay::Effort => "effort",
Overlay::Mcp => "mcp",
Overlay::Todo => "todo",
Overlay::Plan => "plan",
Overlay::Rewind => "rewind",
Overlay::Learning => "learning",
Overlay::Usage => "usage",
Overlay::Loading => "loading",
Overlay::ModelSelector => "model_selector",
Overlay::ClearConfirm => "clear_confirm",
Overlay::Diff => "diff",
}
}
/// Whether any overlay (i.e. anything other than `None`) is active.
pub fn is_active(self) -> bool {
!matches!(self, Overlay::None)
}
}
impl std::fmt::Display for Overlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Simple inline editor state for the TUI.
#[derive(Debug, Clone)]
pub struct EditorState {
/// Path to the file being edited.
pub path: PathBuf,
/// Current buffer content.
pub content: String,
/// Cursor position (byte offset).
pub cursor: usize,
}
impl EditorState {
/// Create a new editor state for the given path.
pub fn new(path: PathBuf, content: String) -> Self {
let cursor = content.len();
EditorState { path, content, cursor }
}
/// Return the full buffer content.
pub fn as_string(&self) -> String {
self.content.clone()
}
/// Delete one character to the left of the cursor.
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.content.remove(self.cursor);
}
}
}
/// The "miscellaneous" slice of app state.
#[derive(Debug, Clone)]
pub struct MiscState {
/// Currently active modal overlay (None = main chat view).
pub overlay: Overlay,
/// Active toast notifications.
pub toasts: Vec<Toast>,
/// Timestamp (ms) of the last staleness sweep for lesson cache.
pub last_staleness_sweep_ms: i64,
/// Whether the agent is currently "thinking".
pub thinking: bool,
/// Current LLM reasoning effort level (1-5).
pub effort_level: usize,
/// Currently focused index in list-type overlays.
pub selected_index: usize,
/// Optional inline editor state.
pub editor: Option<EditorState>,
/// Whether the API connection is established.
pub api_connected: bool,
/// Monotonically increasing tick count, incremented each render frame.
pub tick_count: u64,
/// Cached content of the TODO file.
pub todo_content: String,
/// Cached content of the PLAN file.
pub plan_content: String,
/// Whether a lesson background task is currently running.
pub lesson_running: bool,
/// Text waiting to be written to the system clipboard.
pub pending_clipboard_copy: Option<String>,
/// Cached git diff content for the preview overlay.
pub diff_content: String,
/// Scroll offset for the diff overlay.
pub diff_scroll: usize,
}
impl MiscState {
/// Create a fresh `MiscState` with no overlay, no toasts.
pub fn new() -> Self {
MiscState {
overlay: Overlay::None,
toasts: Vec::new(),
last_staleness_sweep_ms: 0,
thinking: false,
effort_level: 1,
selected_index: 0,
editor: None,
api_connected: false,
tick_count: 0,
todo_content: String::new(),
plan_content: String::new(),
lesson_running: false,
pending_clipboard_copy: None,
diff_content: String::new(),
diff_scroll: 0,
}
}
/// Append a toast notification to the active list.
pub fn push_toast(&mut self, toast: Toast) {
self.toasts.push(toast);
}
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> {
let expired: Vec<_> = self
.toasts
.iter()
.filter(|t| t.expired(now_ms))
.cloned()
.collect();
self.toasts.retain(|t| !t.expired(now_ms));
expired
}
}
impl Default for MiscState {
fn default() -> Self {
Self::new()
}
}
+318
View File
@@ -0,0 +1,318 @@
//! TUI-perspective application state: `AppStateRest` and all the types it
//! owns. This is the single source-of-truth struct for the TUI interface,
//! mutated from `action::apply_action` and read by `view/` every render frame.
//!
//! # Organisation
//!
//! ```text
//! state/
//! ├── mod.rs — AppStateRest (the central struct) + re-exports
//! ├── input.rs — InputState, AutocompleteKind
//! ├── transcript.rs — TranscriptCache, ChatMessageDisplay
//! ├── scroll.rs — ScrollState
//! ├── misc.rs — MiscState, EditorState, Overlay
//! ├── workflow.rs — SimpleAgent, AgentState, SimpleWorkflowEngine
//! └── helpers.rs — Standalone functions operating on AppStateRest
//! ```
//!
//! # Flow
//! Construction in `run.rs::create_local_session` \u{2192} mutated by
//! `action::apply_action` \u{2192} read-only in every `view/*::draw*` function.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tracing::warn;
use zesdex_domain::cms::{AppConfig, Settings};
use ratatui::text::Line;
use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent};
pub mod helpers;
pub mod input;
pub mod misc;
pub mod scroll;
pub mod transcript;
pub mod workflow;
// Re-export all public types from sub-modules at the `state` level so
// consumers that previously used `crate::state::InputState` etc. still work.
pub use input::{AutocompleteKind, InputState};
pub use misc::{EditorState, MiscState, Overlay};
pub use scroll::ScrollState;
pub use transcript::{ChatMessageDisplay, TranscriptCache};
pub use workflow::{AgentState, SimpleAgent, SimpleWorkflowEngine};
// Re-export the most commonly used helpers at the `state` level.
pub use helpers::{
count_tokens, current_effort, cycle_effort, cycle_selected_index, get_learning_items,
rewind_count, resolve_context_window, EFFORT_LEVELS, LearningItem,
};
// ---------------------------------------------------------------------------
// AppStateRest — the single source-of-truth TUI state
// ---------------------------------------------------------------------------
/// The single source-of-truth state struct for the TUI interface.
///
/// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`).
/// Read-only from every `view/*` render function.
#[derive(Clone)]
pub struct AppStateRest {
/// Persistent user settings.
pub settings: Settings,
/// Per-project app configuration.
pub app_config: AppConfig,
/// Absolute paths to each open workspace root directory.
pub workspace_roots: Vec<PathBuf>,
/// Unique session identifier.
pub session_id: String,
/// Path to the session's data directory.
pub session_dir: PathBuf,
/// Path to the session memory directory.
pub memory_dir: PathBuf,
/// Path to the git worktrees directory.
pub worktrees_dir: PathBuf,
/// Shared async cache of directory listings.
pub dir_cache: Arc<tokio::sync::RwLock<DirCache>>,
/// Shared workspace file-path index for `@file` mention autocomplete.
pub mention_index: MentionIndex,
/// Optional per-session runtime state.
pub session_runtime: Option<SessionRuntime>,
/// Ring buffer of recent chat messages for the transcript pane.
pub transcript_cache: TranscriptCache,
/// Viewport scroll offset tracker.
pub scroll: ScrollState,
/// Chat input buffer, cursor, history, and autocomplete.
pub input: InputState,
/// Miscellaneous state: overlay, toasts, flags, editor, tick.
pub misc: MiscState,
/// Queue of events emitted by the running agent turn.
pub turn_events: Arc<std::sync::Mutex<VecDeque<TurnEvent>>>,
/// Whether an agent turn is currently in flight.
/// Uses AtomicBool for lock-free check from render loop.
pub turn_in_flight_flag: Arc<AtomicBool>,
/// Cached display lines for the chat transcript panel.
pub display_lines_cache: Vec<Line<'static>>,
/// Cached token count for the current message history.
pub cached_token_count: usize,
/// Whether the token count cache is stale and needs recalculation.
pub token_count_dirty: bool,
/// Terminal width at the time of the last display_lines_cache rebuild.
pub last_render_width: u16,
/// Number of messages that were in the cache when it was last built.
pub cached_msg_count: usize,
/// Terminal width at the time of the last full cache build.
pub render_width_at_cache: u16,
/// Atomic flag set when the user aborts the current turn.
pub abort_flag: Arc<AtomicBool>,
/// Simplified workflow engine state for display.
pub workflow_engine: SimpleWorkflowEngine,
/// Whether the state has been modified since the last render sweep.
pub dirty: bool,
/// Whether the application has been requested to quit.
pub quit: bool,
/// Cached help text content.
pub help_text: &'static str,
}
/// Default help text shown in the Help overlay.
pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI \u{2014} Keyboard Shortcuts
\u{2500}\u{2500}\u{2500} General \u{2500}\u{2500}\u{2500}
Ctrl+C Quit confirm
Ctrl+D Close overlay
Ctrl+Y Copy last assistant message
Esc Abort turn / Close overlay
Tab Autocomplete
\u{2500}\u{2500}\u{2500} Navigation \u{2500}\u{2500}\u{2500}
\u{2191} / \u{2193} History browse / Overlay navigate
Ctrl+\u{2191}/\u{2193} Scroll transcript
PgUp / PgDown Scroll transcript
Enter Submit / Select autocomplete
\u{2500}\u{2500}\u{2500} Overlays \u{2500}\u{2500}\u{2500}
/help Show this help
/settings Open settings overlay
/todo Open tasks (todo) overlay
/usage Open usage statistics
/bash Open bash jobs overlay
/mcp Open MCP server management
/model Open model selector
/compact Compact conversation
/clear Clear transcript
/rewind Rewind conversation history
\u{2500}\u{2500}\u{2500} Editor Mode \u{2500}\u{2500}\u{2500}
/edit <path> Open file for inline editing
Ctrl+S Save changes
Esc Dismiss editor
"#;
impl Default for AppStateRest {
fn default() -> Self {
AppStateRest {
settings: Settings::default(),
app_config: AppConfig::default(),
workspace_roots: Vec::new(),
session_id: String::new(),
session_dir: PathBuf::new(),
memory_dir: PathBuf::new(),
worktrees_dir: PathBuf::new(),
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
mention_index: MentionIndex::new(),
session_runtime: None,
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)),
workflow_engine: SimpleWorkflowEngine::new(),
dirty: true,
quit: false,
help_text: DEFAULT_HELP_TEXT,
display_lines_cache: Vec::new(),
cached_token_count: 0,
token_count_dirty: true,
last_render_width: 0,
cached_msg_count: 0,
render_width_at_cache: 0,
}
}
}
impl AppStateRest {
/// Construct initial TUI state.
pub fn new(
workspace_roots: Vec<PathBuf>,
session_dir: &std::path::Path,
memory_dir: PathBuf,
) -> Self {
let settings = Settings::default();
let app_config = AppConfig::default();
let worktrees_dir = memory_dir
.parent()
.unwrap_or(&memory_dir)
.join("worktrees");
let session_id = session_dir.file_name().map_or_else(
|| {
warn!("[state] session_dir has no file_name, using empty session_id");
String::new()
},
|n| n.to_string_lossy().to_string(),
);
AppStateRest {
settings,
app_config,
workspace_roots,
session_id,
session_dir: session_dir.to_path_buf(),
memory_dir: memory_dir.clone(),
worktrees_dir,
turn_events: Arc::new(std::sync::Mutex::new(VecDeque::new())),
turn_in_flight_flag: Arc::new(AtomicBool::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)),
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
mention_index: MentionIndex::new(),
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
workflow_engine: SimpleWorkflowEngine::new(),
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
dirty: true,
quit: false,
help_text: DEFAULT_HELP_TEXT,
display_lines_cache: Vec::new(),
cached_token_count: 0,
token_count_dirty: true,
last_render_width: 0,
cached_msg_count: 0,
render_width_at_cache: 0,
}
}
/// Whether an agent turn is currently running.
/// Uses lock-free AtomicBool load \u{2014} safe to call every render frame.
pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight_flag.load(Ordering::Relaxed)
}
/// Append a message to the transcript.
/// Eviction is O(1) via VecDeque::pop_front.
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.push(msg);
self.token_count_dirty = true;
self.dirty = true;
}
/// Append text to the last assistant message in the transcript, if one exists.
pub fn append_to_last_transcript(&mut self, text: &str, is_reasoning: bool) {
self.transcript_cache.append_to_last(text, is_reasoning);
if self.transcript_cache.dirty {
self.dirty = true;
}
}
/// Mark the app state as dirty, triggering a TUI re-render.
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
/// Queue a toast notification.
pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast);
self.mark_dirty();
}
/// Push an info toast.
pub fn toast_info(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Info, msg.into()));
}
/// Push a success toast.
pub fn toast_success(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Success, msg.into()));
}
/// Push a warning toast.
pub fn toast_warning(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Warning, msg.into()));
}
/// Push an error toast.
pub fn toast_error(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Error, msg.into()));
}
/// Persist settings to disk.
pub fn save_settings(&self) {
if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) {
let repo =
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new();
use zesdex_domain::SettingsRepository;
if let Err(e) = repo.save(&store_dir, &self.settings) {
tracing::warn!("Failed to save settings: {e}");
}
}
}
/// Resolve the base directory for session stores.
pub fn store_base_dir(&self) -> PathBuf {
self.session_dir
.parent()
.and_then(|p| p.parent())
.map_or_else(
|| {
warn!("[state] no grandparent, using session_dir");
self.session_dir.clone()
},
std::path::Path::to_path_buf,
)
}
}
+36
View File
@@ -0,0 +1,36 @@
//! Viewport scroll state: current offset and visible-line count.
/// Viewport scroll state: current offset and visible-line count.
#[derive(Debug, Clone)]
pub struct ScrollState {
/// Current scroll offset (how many lines have been scrolled past).
pub offset: usize,
/// Maximum number of lines that fit in the visible viewport area.
pub max_visible: usize,
}
impl ScrollState {
/// Create a `ScrollState` with zero offset and 30 rows visible.
pub fn new() -> Self {
ScrollState {
offset: 0,
max_visible: 30,
}
}
/// Scroll the viewport up by `amount` lines (increasing the offset).
pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_add(amount);
}
/// Scroll the viewport down by `amount` lines (decreasing the offset).
pub fn scroll_down(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount);
}
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,76 @@
//! Transcript display types: bounded ring-buffer cache of rendered messages.
use std::collections::VecDeque;
use zesdex_domain::core::Role;
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
/// Message author: User or Assistant.
pub role: Role,
/// Rendered text content (plain text, no markdown).
pub content: String,
/// Model reasoning (e.g. from DeepSeek-R1 <think> block).
pub reasoning: String,
/// Millisecond timestamp when this display entry was created.
pub timestamp: i64,
}
impl ChatMessageDisplay {
/// Build a display entry, stamping it with the current time.
pub fn new(role: Role, content: String) -> Self {
ChatMessageDisplay {
role,
content,
reasoning: String::new(),
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
}
/// Bounded ring of recent chat messages used to render the transcript view.
#[derive(Debug, Clone)]
pub struct TranscriptCache {
/// Ordered display messages (newest appended, oldest evicted when full).
/// Uses VecDeque for O(1) front eviction instead of O(n) Vec::remove(0).
pub messages: VecDeque<ChatMessageDisplay>,
/// Maximum messages to retain before evicting the oldest.
pub max_lines: usize,
/// Whether the cache has changed since the last render sweep.
pub dirty: bool,
}
impl TranscriptCache {
/// Create an empty transcript cache holding at most `max_lines` messages.
pub fn new(max_lines: usize) -> Self {
TranscriptCache {
messages: VecDeque::new(),
max_lines,
dirty: true,
}
}
/// Append a message, evicting the oldest if at capacity.
/// Eviction is O(1) via VecDeque::pop_front instead of O(n) Vec::remove(0).
pub fn push(&mut self, msg: ChatMessageDisplay) {
self.messages.push_back(msg);
while self.messages.len() > self.max_lines {
self.messages.pop_front();
}
self.dirty = true;
}
/// Append text to the last assistant message, if one exists.
pub fn append_to_last(&mut self, text: &str, is_reasoning: bool) {
if let Some(msg) = self.messages.back_mut() {
if msg.role == Role::Assistant {
if is_reasoning {
msg.reasoning.push_str(text);
} else {
msg.content.push_str(text);
}
self.dirty = true;
}
}
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Simplified agent lifecycle display types for the workflow sidebar.
/// Simplified agent lifecycle state for TUI display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentState {
Idle,
Running,
Completed,
Failed,
}
/// A single agent entry in the workflow sidebar.
#[derive(Debug, Clone)]
pub struct SimpleAgent {
/// Agent unique ID (e.g. "auto-review", "Node-0-1").
pub name: String,
/// Human-readable display label (e.g. "Auto-Review", "Backend API Agent").
pub display_name: String,
/// Current lifecycle state.
pub state: AgentState,
/// Millisecond timestamp when the agent started.
pub started_at: Option<i64>,
/// Millisecond timestamp when the agent completed.
pub completed_at: Option<i64>,
/// Optional error message if the agent failed.
pub error: Option<String>,
/// Optional progress text (current tool, step description).
pub progress: Option<String>,
}
impl SimpleAgent {
/// Create a new agent with the given name (used as both ID and display name).
pub fn new(name: String) -> Self {
SimpleAgent {
display_name: name.clone(),
name,
state: AgentState::Idle,
started_at: None,
completed_at: None,
error: None,
progress: None,
}
}
/// Create a new agent with separate ID and display label.
pub fn with_display(name: String, display_name: String) -> Self {
SimpleAgent {
name,
display_name,
state: AgentState::Idle,
started_at: None,
completed_at: None,
error: None,
progress: None,
}
}
}
/// Simplified workflow engine state for TUI display.
#[derive(Debug, Clone)]
pub struct SimpleWorkflowEngine {
/// Active agents in the workflow.
pub agents: Vec<SimpleAgent>,
/// Summary findings produced by completed agents.
pub findings: Vec<String>,
}
impl SimpleWorkflowEngine {
/// Create an empty workflow engine state.
pub fn new() -> Self {
SimpleWorkflowEngine {
agents: Vec::new(),
findings: Vec::new(),
}
}
}
impl Default for SimpleWorkflowEngine {
fn default() -> Self {
Self::new()
}
}
+69 -42
View File
@@ -1,22 +1,74 @@
//! TUI agent turn interface adapter — delegates execution to `zesdex_infrastructure::agent`.
//! TUI agent turn interface adapter — resolves LLM provider configuration,
//! builds the tool context, and spawns the agent turn on a background task.
use std::sync::atomic::Ordering;
use tracing::info;
use zesdex_domain::core::ChatMessage;
use zesdex_domain::agent::AgentTurnParams;
use zesdex_infrastructure::llm::provider::LlmClient;
use zesdex_infrastructure::tools::executor::InfrastructureToolExecutor;
use zesdex_infrastructure::tools::{all_tools, tool_defs, ToolCtx};
use zesdex_application::agent::turn_service::AgentTurnServiceImpl;
use zesdex_application::agent::AgentTurnService;
use crate::state::AppStateRest;
/// Spawn an agent turn on a background thread by delegating to `zesdex-infrastructure`.
// ---------------------------------------------------------------------------
// Provider resolution
// ---------------------------------------------------------------------------
/// Resolve the API key from settings or environment for the given provider.
fn resolve_api_key(state: &AppStateRest, provider_name: &str) -> String {
if let Some(key) = state.settings.api_keys.get(provider_name) {
return key.clone();
}
if let Some(ref cfg) = state.app_config.providers.get(provider_name) {
if let Some(ref default_key) = cfg.default_api_key {
if !default_key.is_empty() {
return default_key.clone();
}
}
if let Some(ref env_name) = cfg.api_key_env {
if let Ok(val) = std::env::var(env_name) {
return val;
}
}
}
String::new()
}
/// Resolve the API base URL from the provider config.
fn resolve_api_base(state: &AppStateRest, provider_name: &str) -> Option<String> {
state
.app_config
.providers
.get(provider_name)
.map(|cfg| cfg.api_base.clone())
}
// ---------------------------------------------------------------------------
// Turn spawning
// ---------------------------------------------------------------------------
/// Spawn an agent turn on a background Tokio task.
///
/// Flow:
/// 1. Compare-exchange the in-flight flag (no-op if already running).
/// 2. Resolve provider (API key, model, base URL) from settings.
/// 3. Build messages including the user's input text.
/// 4. Construct `AgentTurnParams` with the turn-event queue and abort flag.
/// 5. Create `LlmClient`, `ToolCtx`, and `InfrastructureToolExecutor`.
/// 6. Assemble `AgentTurnServiceImpl` and spawn it via `tokio::spawn`.
#[tracing::instrument(skip(state))]
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
// compare_exchange: only mark in-flight if not already running
// Only one turn at a time — compare_exchange is lock-free
if state
.turn_in_flight_flag
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
.is_err()
{
return; // already running
return;
}
let turn_events = state.turn_events.clone();
@@ -25,29 +77,13 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
let session_dir = state.session_dir.clone();
let workspace_roots = state.workspace_roots.clone();
// Resolve LLM provider configuration from settings
// ── Resolve provider configuration ─────────────────────────────────
let provider_name = &state.settings.provider;
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
let mut api_key = String::new();
if let Some(key) = state.settings.api_keys.get(provider_name) {
api_key = key.clone();
} else if let Some(ref cfg) = provider_cfg {
if let Some(ref default_key) = cfg.default_api_key {
api_key = default_key.clone();
}
if api_key.is_empty() {
if let Some(ref env_name) = cfg.api_key_env {
if let Ok(val) = std::env::var(env_name) {
api_key = val;
}
}
}
}
let api_key = resolve_api_key(state, provider_name);
let model = state.settings.model.clone();
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
let api_base = resolve_api_base(state, provider_name);
// ── Build message list ─────────────────────────────────────────────
let mut messages: Vec<ChatMessage> = state
.session_runtime
.as_ref()
@@ -59,8 +95,9 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
rt.messages = messages.clone();
}
info!("delegating agent turn to infrastructure engine (model: {})", model);
info!("Spawning agent turn (model: {model})");
// ── Assemble dependencies (composition root) ──────────────────────
let params = AgentTurnParams {
messages,
session_dir: session_dir.clone(),
@@ -73,32 +110,22 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
api_base: api_base.clone(),
};
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
api_key,
model,
api_base,
));
let client = std::sync::Arc::new(LlmClient::new(api_key, model, api_base));
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
let tool_ctx = ToolCtx::builder()
.session_dir(session_dir)
.workspaces(workspace_roots)
.turn_events(turn_events)
.build();
let tool_executor = std::sync::Arc::new(
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
);
let tool_executor =
std::sync::Arc::new(InfrastructureToolExecutor::new(tool_ctx));
let tools = zesdex_infrastructure::tools::all_tools();
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
let tools = all_tools();
let defs = tool_defs(&tools);
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
client,
tool_executor,
defs,
);
let turn_service = AgentTurnServiceImpl::new(client, tool_executor, defs);
use zesdex_application::agent::AgentTurnService;
tokio::spawn(async move {
let _ = turn_service.run_turn(params).await;
});
@@ -31,7 +31,7 @@ pub fn render(
let summary = runtime.map(|r| compute_usage_summary(&r.usage, r.session_start, now_ms));
let (edit_count, lesson_count, review_count, consec_empty) =
runtime.map_or((0, 0, 0, 0), |r| {
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
(r.edit_count, r.lessons.total, r.review_count, r.consecutive_empty_reviews)
});
let mut lines = vec![
Line::from(Span::styled(