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