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::agent::{AgentTurnParams, TurnEvent};
|
||||||
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
use zesdex_domain::core::{ChatMessage, StreamEvent, ToolDef};
|
||||||
|
use zesdex_domain::main_agent_prompt;
|
||||||
|
|
||||||
use crate::ports::ProviderService;
|
use crate::ports::ProviderService;
|
||||||
use super::ToolExecutor;
|
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.
|
/// Service implementation for executing an agent turn asynchronously.
|
||||||
pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
|
pub struct AgentTurnServiceImpl<P: ProviderService, T: ToolExecutor> {
|
||||||
provider: Arc<P>,
|
provider: Arc<P>,
|
||||||
@@ -24,15 +121,27 @@ impl<P: ProviderService, T: ToolExecutor> AgentTurnServiceImpl<P, T> {
|
|||||||
tool_defs,
|
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>) {
|
/// Execute a single LLM call with the current message list, handling
|
||||||
flag.store(false, Ordering::SeqCst);
|
/// 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
|
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\
|
// Insert system prompt at position 0 once and keep it there for the
|
||||||
CRITICAL DIRECTIVES & PRIORITY HIERARCHY:\n\
|
// entire turn, avoiding per-iteration clones of the full message list.
|
||||||
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\
|
// It is removed before emitting the Compacted event so persistence
|
||||||
2. PLANNING & TODOS: Use `plan_enter` to establish high-level architectural plans and `todowrite` to maintain granular task checklists.\n\
|
// does not store the prompt redundantly.
|
||||||
3. REASONING: Use `seq_think` for deep step-by-step analysis.\n\
|
params.messages.insert(0, ChatMessage::system(main_agent_prompt()));
|
||||||
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\
|
let original_count = params.messages.len();
|
||||||
Respond conversationally, concisely, and helpfully.".to_string();
|
|
||||||
|
|
||||||
let sys_msg = ChatMessage::system(sys_prompt);
|
for iteration in 0..MAX_TURN_ITERATIONS {
|
||||||
|
// ── Check abort flag ────────────────────────────────────────
|
||||||
for iteration in 0..50 {
|
|
||||||
if params.abort.load(Ordering::SeqCst) {
|
if params.abort.load(Ordering::SeqCst) {
|
||||||
params.abort.store(false, Ordering::SeqCst);
|
params.abort.store(false, Ordering::SeqCst);
|
||||||
Self::push_event(
|
push_event(
|
||||||
¶ms.turn_events,
|
¶ms.turn_events,
|
||||||
TurnEvent::SystemNote {
|
TurnEvent::SystemNote {
|
||||||
kind: "info".into(),
|
kind: "info".into(),
|
||||||
@@ -69,58 +176,28 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
|
|||||||
|
|
||||||
debug!("agent turn iteration {iteration}");
|
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();
|
// Uses params.messages directly (sys_msg[0] already in place
|
||||||
req_messages.insert(0, sys_msg.clone());
|
// from the insert above) — no per-iteration clone needed.
|
||||||
|
let result = self
|
||||||
let abort_clone = Arc::clone(¶ms.abort);
|
.call_llm(¶ms.messages, ¶ms.abort, ¶ms.turn_events)
|
||||||
let turn_events_clone = Arc::clone(¶ms.turn_events);
|
.await;
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok((assistant_msg, usage)) => {
|
Ok((assistant_msg, usage)) => {
|
||||||
let content = assistant_msg.content.clone().unwrap_or_default();
|
let content = assistant_msg.content.clone().unwrap_or_default();
|
||||||
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
||||||
|
|
||||||
Self::push_event(
|
push_event(
|
||||||
¶ms.turn_events,
|
¶ms.turn_events,
|
||||||
TurnEvent::StreamDone(assistant_msg.clone()),
|
TurnEvent::StreamDone(assistant_msg.clone()),
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some((tokens_in, tokens_out)) = usage {
|
emit_usage(¶ms.turn_events, usage);
|
||||||
Self::push_event(
|
|
||||||
¶ms.turn_events,
|
|
||||||
TurnEvent::Usage {
|
|
||||||
tokens_in,
|
|
||||||
tokens_out,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ── No tool calls → assistant is done ──────────────
|
||||||
if tool_calls.is_empty() {
|
if tool_calls.is_empty() {
|
||||||
params.messages.push(ChatMessage::assistant(Some(content)));
|
params.messages.push(ChatMessage::assistant(Some(content)));
|
||||||
break;
|
break;
|
||||||
@@ -128,86 +205,76 @@ impl<P: ProviderService, T: ToolExecutor> super::AgentTurnService for AgentTurnS
|
|||||||
|
|
||||||
params.messages.push(assistant_msg);
|
params.messages.push(assistant_msg);
|
||||||
|
|
||||||
|
// ── Execute each tool call ──────────────────────────
|
||||||
for tc in &tool_calls {
|
for tc in &tool_calls {
|
||||||
let name = &tc.function.name;
|
let output =
|
||||||
let args = zesdex_domain::core::tool_call::sanitize_tool_arguments(&tc.function.arguments);
|
execute_tool_call(self.tool_executor.as_ref(), ¶ms.turn_events, tc).await;
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
params
|
params
|
||||||
.messages
|
.messages
|
||||||
.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
.push(ChatMessage::tool(tc.id.clone(), output));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("LLM call failed: {e}");
|
warn!("{e}");
|
||||||
Self::push_event(
|
push_event(
|
||||||
¶ms.turn_events,
|
¶ms.turn_events,
|
||||||
TurnEvent::Error(format!("LLM error: {e}")),
|
TurnEvent::Error(e),
|
||||||
);
|
);
|
||||||
break;
|
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,
|
¶ms.turn_events,
|
||||||
TurnEvent::Compacted(params.messages.clone()),
|
TurnEvent::Compacted(compacted),
|
||||||
);
|
);
|
||||||
Self::push_event(¶ms.turn_events, TurnEvent::Done);
|
push_event(¶ms.turn_events, TurnEvent::Done);
|
||||||
Self::mark_done(¶ms.in_flight);
|
params.in_flight.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
Ok(())
|
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>(
|
pub async fn compact_messages_with_ai<P: ProviderService>(
|
||||||
messages: &mut Vec<ChatMessage>,
|
messages: &mut Vec<ChatMessage>,
|
||||||
provider: &P,
|
provider: &P,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
const KEEP_TAIL: usize = 6;
|
if messages.len() <= COMPACT_KEEP_TAIL + 2 {
|
||||||
if messages.len() <= KEEP_TAIL + 2 {
|
|
||||||
return Ok(()); // Not enough messages to compact
|
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 evicted: Vec<_> = messages.drain(..split_idx).collect();
|
||||||
|
|
||||||
let mut summary_prompt = vec![
|
let mut summary_prompt = vec![
|
||||||
ChatMessage::system(
|
ChatMessage::system(zesdex_domain::compaction_prompt()),
|
||||||
"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(),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
summary_prompt.extend(evicted);
|
summary_prompt.extend(evicted);
|
||||||
summary_prompt.push(ChatMessage::user(
|
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 {
|
match provider.chat(&summary_prompt, None, Some(1024), Some(0.3)).await {
|
||||||
Ok((summary_msg, _)) => {
|
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!(
|
let summary_node = ChatMessage::system(format!(
|
||||||
"[AI Summary of Previous Conversation]\n{}",
|
"[AI Summary of Previous Conversation]\n{}",
|
||||||
summary_text.trim()
|
summary_text.trim()
|
||||||
@@ -216,12 +283,16 @@ pub async fn compact_messages_with_ai<P: ProviderService>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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(
|
messages.insert(
|
||||||
0,
|
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(())
|
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 serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::core::{ChatMessage, ToolCallResult, UsageStats};
|
use crate::core::{ChatMessage, ToolCallResult, UsageStats};
|
||||||
|
|
||||||
|
pub mod prompt;
|
||||||
|
pub mod progress;
|
||||||
|
|
||||||
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
/// Which kind of caller (main agent vs. subagent vs. reviewer) is
|
||||||
/// invoking a tool, used to scope permissions and tag log/output paths.
|
/// invoking a tool, used to scope permissions and tag log/output paths.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
|
||||||
@@ -124,6 +130,9 @@ pub enum TurnEvent {
|
|||||||
},
|
},
|
||||||
TodoUpdate(String),
|
TodoUpdate(String),
|
||||||
PlanUpdate(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.
|
/// How a pending tool call should be executed when the turn resumes.
|
||||||
@@ -152,6 +161,33 @@ pub struct BashJobRef {
|
|||||||
pub running: bool,
|
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,
|
/// Per-session runtime state: message history, pending tool queue,
|
||||||
/// background bash jobs, lesson/review counters.
|
/// background bash jobs, lesson/review counters.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -164,17 +200,8 @@ pub struct SessionRuntime {
|
|||||||
pub edit_count: u32,
|
pub edit_count: u32,
|
||||||
pub consecutive_empty_reviews: u32,
|
pub consecutive_empty_reviews: u32,
|
||||||
pub session_start: i64,
|
pub session_start: i64,
|
||||||
pub lesson_count: u32,
|
/// Aggregated lesson statistics.
|
||||||
pub lessons_user: u32,
|
pub lessons: LessonStats,
|
||||||
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,
|
|
||||||
pub review_count: u32,
|
pub review_count: u32,
|
||||||
pub session_dir: PathBuf,
|
pub session_dir: PathBuf,
|
||||||
pub usage: UsageStats,
|
pub usage: UsageStats,
|
||||||
@@ -192,17 +219,7 @@ impl SessionRuntime {
|
|||||||
edit_count: 0,
|
edit_count: 0,
|
||||||
consecutive_empty_reviews: 0,
|
consecutive_empty_reviews: 0,
|
||||||
session_start: chrono::Utc::now().timestamp_millis(),
|
session_start: chrono::Utc::now().timestamp_millis(),
|
||||||
lesson_count: 0,
|
lessons: LessonStats::default(),
|
||||||
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,
|
|
||||||
review_count: 0,
|
review_count: 0,
|
||||||
session_dir,
|
session_dir,
|
||||||
usage: UsageStats::default(),
|
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,
|
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
|
||||||
};
|
};
|
||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
|
||||||
|
// Agent module top-level items (TurnEvent, SessionRuntime, etc.)
|
||||||
pub use agent::*;
|
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 workflow::*;
|
||||||
pub use subagent::*;
|
pub use subagent::*;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
//! Flow: construct system message → call LLM → parse tool calls → execute
|
//! Flow: construct system message → call LLM → parse tool calls → execute
|
||||||
//! tools → continue until the model returns a final text response (no more
|
//! tools → continue until the model returns a final text response (no more
|
||||||
//! tool calls) or the iteration limit is reached.
|
//! 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 anyhow::Result;
|
||||||
use tracing::{debug, info, instrument};
|
use tracing::{debug, info, instrument};
|
||||||
@@ -11,17 +15,44 @@ use crate::llm::provider::LlmClient;
|
|||||||
use crate::subagent::context::SubagentContext;
|
use crate::subagent::context::SubagentContext;
|
||||||
use crate::subagent::division::{tools_for, AccessTier};
|
use crate::subagent::division::{tools_for, AccessTier};
|
||||||
use crate::tools::{tool_defs, ToolCtx};
|
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::tool_call::sanitize_tool_arguments;
|
||||||
use zesdex_domain::core::ChatMessage;
|
use zesdex_domain::core::ChatMessage;
|
||||||
|
use zesdex_domain::subagent_directive;
|
||||||
|
|
||||||
/// Maximum number of tool-call iterations before the engine gives up.
|
/// Maximum number of tool-call iterations before the engine gives up.
|
||||||
const MAX_ITERATIONS: u32 = 25;
|
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.
|
/// Run an agent with a directive, access tier, and tool context.
|
||||||
///
|
///
|
||||||
/// Flow:
|
/// Flow:
|
||||||
/// 1. Resolve allowed tools for the given `access` tier.
|
/// 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`):
|
/// 3. Loop (up to `MAX_ITERATIONS`):
|
||||||
/// a. Call the LLM (non-streaming) with accumulated messages + tool defs.
|
/// a. Call the LLM (non-streaming) with accumulated messages + tool defs.
|
||||||
/// b. If the response has no tool calls → return the text content.
|
/// b. If the response has no tool calls → return the text content.
|
||||||
@@ -29,6 +60,9 @@ const MAX_ITERATIONS: u32 = 25;
|
|||||||
/// tool-role message.
|
/// tool-role message.
|
||||||
/// d. If the response also contained text, append an assistant message.
|
/// d. If the response also contained text, append an assistant message.
|
||||||
/// 4. If the loop exits naturally, return the iteration-limit 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))]
|
#[instrument(skip(ctx, tool_ctx))]
|
||||||
pub async fn run_agent(
|
pub async fn run_agent(
|
||||||
ctx: SubagentContext,
|
ctx: SubagentContext,
|
||||||
@@ -41,23 +75,9 @@ pub async fn run_agent(
|
|||||||
let tools = tools_for(&access);
|
let tools = tools_for(&access);
|
||||||
let defs = tool_defs(&tools);
|
let defs = tool_defs(&tools);
|
||||||
|
|
||||||
let cwd = std::env::current_dir()
|
let sys_msg = build_system_message(directive, &tool_ctx);
|
||||||
.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 mut messages = vec![ChatMessage::system(format!(
|
let mut messages = vec![sys_msg];
|
||||||
"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 client = LlmClient::new(
|
let client = LlmClient::new(
|
||||||
ctx.api_key.clone(),
|
ctx.api_key.clone(),
|
||||||
@@ -68,12 +88,9 @@ pub async fn run_agent(
|
|||||||
// Limited iteration loop so we don't run forever
|
// Limited iteration loop so we don't run forever
|
||||||
for iteration in 0..MAX_ITERATIONS {
|
for iteration in 0..MAX_ITERATIONS {
|
||||||
use zesdex_application::ports::ProviderService;
|
use zesdex_application::ports::ProviderService;
|
||||||
let (response_msg, _usage) = client.chat(
|
let (response_msg, _usage) = client
|
||||||
&messages,
|
.chat(&messages, Some(defs.clone()), Some(4096), None)
|
||||||
Some(defs.clone()),
|
.await?;
|
||||||
Some(4096),
|
|
||||||
None,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
let content = response_msg.content.clone().unwrap_or_default();
|
let content = response_msg.content.clone().unwrap_or_default();
|
||||||
let tool_calls = response_msg.tool_calls.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 no tool calls, we're done — return content
|
||||||
if tool_calls.is_empty() {
|
if tool_calls.is_empty() {
|
||||||
info!("Subagent completed after {iteration} iterations");
|
info!("Subagent completed after {iteration} iterations");
|
||||||
|
report_progress(
|
||||||
|
&tool_ctx,
|
||||||
|
AgentProgress::completed("subagent", directive),
|
||||||
|
);
|
||||||
return Ok(content);
|
return Ok(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,14 +112,24 @@ pub async fn run_agent(
|
|||||||
|
|
||||||
debug!("Subagent executing tool: {tool_name}");
|
debug!("Subagent executing tool: {tool_name}");
|
||||||
|
|
||||||
let result = if let Some(tool) = tools.iter().find(|t| t.name() == tool_name) {
|
report_progress(
|
||||||
match tool.run(&tool_ctx, &args) {
|
&tool_ctx,
|
||||||
Ok(output) => output,
|
AgentProgress::running(
|
||||||
Err(e) => format!("Error: {e}"),
|
"subagent",
|
||||||
}
|
format!("{}:{}", directive, tool_name),
|
||||||
} else {
|
Some(tool_name.clone()),
|
||||||
format!("Unknown 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}")
|
||||||
|
};
|
||||||
|
|
||||||
messages.push(ChatMessage::tool(tc.id.clone(), result));
|
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.
|
//! Tool trait, execution context, and the registry of all built-in tools.
|
||||||
//!
|
//!
|
||||||
//! This module defines the core `Tool` trait that every agent-invocable tool
|
//! This module defines the core `Tool` trait that every agent-invocable tool
|
||||||
//! must implement, the shared `ToolCtx` execution context passed to every tool
|
//! must implement, the shared `ToolCtx` execution context, and utility
|
||||||
//! invocation, and utility functions for path resolution, command execution,
|
//! functions for path resolution, command execution, argument extraction,
|
||||||
//! argument extraction, and edit-log persistence.
|
//! 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 anyhow::Result;
|
||||||
use serde_json::Value;
|
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 bash_tools;
|
||||||
|
pub mod context;
|
||||||
|
pub mod executor;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
|
pub mod graduated;
|
||||||
pub mod lsp;
|
pub mod lsp;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod parallel_delegate;
|
pub mod parallel_delegate;
|
||||||
pub mod plan;
|
pub mod plan;
|
||||||
|
pub mod registry;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod semantic_search;
|
pub mod semantic_search;
|
||||||
pub mod sequential_think;
|
pub mod sequential_think;
|
||||||
@@ -28,13 +52,16 @@ pub mod shell;
|
|||||||
pub mod shell_filter;
|
pub mod shell_filter;
|
||||||
pub mod spawn;
|
pub mod spawn;
|
||||||
pub mod utility;
|
pub mod utility;
|
||||||
|
pub mod util;
|
||||||
pub mod web_search;
|
pub mod web_search;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
pub mod executor;
|
|
||||||
|
|
||||||
pub use git::git_cred;
|
// Re-export commonly used items at the `tools` root so existing imports
|
||||||
pub use git::git_operator;
|
// like `crate::tools::{Tool, ToolCtx}` continue to work.
|
||||||
pub use git::git_worktree;
|
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.
|
/// Common interface every agent-invocable tool implements.
|
||||||
pub trait Tool: Send + Sync {
|
pub trait Tool: Send + Sync {
|
||||||
@@ -44,340 +71,6 @@ pub trait Tool: Send + Sync {
|
|||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A project-defined rule that flags a matching file path or content pattern
|
pub use git::git_cred;
|
||||||
/// for review.
|
pub use git::git_operator;
|
||||||
#[derive(Debug, Clone)]
|
pub use git::git_worktree;
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 std::sync::atomic::Ordering;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use zesdex_domain::core::ChatMessage;
|
use zesdex_domain::core::ChatMessage;
|
||||||
use zesdex_domain::agent::AgentTurnParams;
|
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;
|
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))]
|
#[tracing::instrument(skip(state))]
|
||||||
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
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
|
if state
|
||||||
.turn_in_flight_flag
|
.turn_in_flight_flag
|
||||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::Relaxed)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
return; // already running
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let turn_events = state.turn_events.clone();
|
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 session_dir = state.session_dir.clone();
|
||||||
let workspace_roots = state.workspace_roots.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_name = &state.settings.provider;
|
||||||
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
|
let api_key = resolve_api_key(state, provider_name);
|
||||||
|
|
||||||
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 model = state.settings.model.clone();
|
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
|
let mut messages: Vec<ChatMessage> = state
|
||||||
.session_runtime
|
.session_runtime
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -59,8 +95,9 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
|||||||
rt.messages = messages.clone();
|
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 {
|
let params = AgentTurnParams {
|
||||||
messages,
|
messages,
|
||||||
session_dir: session_dir.clone(),
|
session_dir: session_dir.clone(),
|
||||||
@@ -73,32 +110,22 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
|||||||
api_base: api_base.clone(),
|
api_base: api_base.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
|
let client = std::sync::Arc::new(LlmClient::new(api_key, model, api_base));
|
||||||
api_key,
|
|
||||||
model,
|
|
||||||
api_base,
|
|
||||||
));
|
|
||||||
|
|
||||||
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
|
let tool_ctx = ToolCtx::builder()
|
||||||
.session_dir(session_dir)
|
.session_dir(session_dir)
|
||||||
.workspaces(workspace_roots)
|
.workspaces(workspace_roots)
|
||||||
.turn_events(turn_events)
|
.turn_events(turn_events)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let tool_executor = std::sync::Arc::new(
|
let tool_executor =
|
||||||
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
|
std::sync::Arc::new(InfrastructureToolExecutor::new(tool_ctx));
|
||||||
);
|
|
||||||
|
|
||||||
let tools = zesdex_infrastructure::tools::all_tools();
|
let tools = all_tools();
|
||||||
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
|
let defs = tool_defs(&tools);
|
||||||
|
|
||||||
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
|
let turn_service = AgentTurnServiceImpl::new(client, tool_executor, defs);
|
||||||
client,
|
|
||||||
tool_executor,
|
|
||||||
defs,
|
|
||||||
);
|
|
||||||
|
|
||||||
use zesdex_application::agent::AgentTurnService;
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = turn_service.run_turn(params).await;
|
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 summary = runtime.map(|r| compute_usage_summary(&r.usage, r.session_start, now_ms));
|
||||||
let (edit_count, lesson_count, review_count, consec_empty) =
|
let (edit_count, lesson_count, review_count, consec_empty) =
|
||||||
runtime.map_or((0, 0, 0, 0), |r| {
|
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![
|
let mut lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
|
|||||||
Reference in New Issue
Block a user