Refactor and clean up code across multiple modules

- Simplified token type assignment in OAuth service.
- Removed unused session_lock module and re-exported Session from zesdex_entities.
- Cleaned up session entity by removing unnecessary comments and code.
- Consolidated session handling in HTTP handlers for better readability.
- Improved formatting and readability in OAuth repository tests.
- Enhanced session lock repository with clearer match statements.
- Streamlined session repository error handling.
- Refined RNG tests for better clarity.
- Adjusted module visibility and organization in lib.rs.
- Updated IPC client and connection code for better error handling and clarity.
- Improved frame handling in IPC for better readability.
- Organized module imports and added test utilities for IPC.
- Enhanced database connection error handling.
- Simplified JWT token creation error handling.
- Improved password verification error handling.
- Cleaned up state management code for better readability.
- Refactored middleware for session authentication and rate limiting.
- Simplified clipboard utility for better error handling.
- Enhanced logging initialization for better error reporting.
- Improved pagination utility with clearer method annotations.
- Cleaned up sanitization functions for filenames and paths.
- Enhanced slug generation functions for better clarity and usability.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 22dd6fdda7
commit 1f0ae9f551
95 changed files with 1792 additions and 2131 deletions
@@ -54,9 +54,15 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
}
// 2. Load stored memory lessons from long-term memory directory
let names = zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().list(&state.memory_dir).unwrap_or_default();
let names =
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
.list(&state.memory_dir)
.unwrap_or_default();
for name in names {
if let Ok(mem) = zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new().load(&state.memory_dir, &name) {
if let Ok(mem) =
zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new()
.load(&state.memory_dir, &name)
{
if mem.kind == "lesson" {
items.push(LearningItem::Stored {
name: mem.name,
+2 -1
View File
@@ -101,7 +101,8 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) {
}
// Log the rewind itself as an edit entry
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
let repo =
zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(&state.session_dir) {
let entry = zesdex_cms::domain::edit_log::EditLogEntry {
ts: chrono::Utc::now().timestamp_millis(),
+129 -48
View File
@@ -1,7 +1,11 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Adaptive quality-review triggering, build/test probing, staleness
//! sweeps for stored lessons, and the pending-lesson approval workflow.
use std::process::Command;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{Origin, Toast, ToastKind};
@@ -9,6 +13,7 @@ use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
use serde::{Deserialize, Serialize};
use std::process::Command;
use zesdex_cms::domain::memory::Memory;
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
@@ -74,11 +79,12 @@ pub struct Lesson {
///
/// Return: `true` if a review should be triggered this turn.
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if origin != Origin::Main {
return false;
}
let Some(runtime) = &state.session_runtime else { return false };
let Some(runtime) = &state.session_runtime else {
return false;
};
if !state.settings.flags.review_enabled {
return false;
}
@@ -119,18 +125,28 @@ pub struct ProbeResult {
/// Return: `None` if no workspace exists, no command could be resolved,
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
/// describing pass/fail/timeout and truncated output.
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
pub fn probe_build_test(
workspaces: &[std::path::PathBuf],
verify_command: Option<&str>,
timeout_ms: u64,
) -> Option<ProbeResult> {
let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?;
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()));
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(
|| (cmd.clone(), String::new()),
|(p, a)| (p.to_string(), a.to_string()),
);
let Ok(mut child) = Command::new(&cmd_prog)
.args(cmd_args.split_whitespace())
.current_dir(probe_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn() else { return None };
.spawn()
else {
return None;
};
let start = std::time::Instant::now();
let timed_out = loop {
@@ -141,9 +157,19 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
match child.try_wait() {
Ok(Some(status)) => {
let output = child.wait_with_output().ok();
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
let stdout = output
.as_ref()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default();
let stderr = output
.as_ref()
.map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string())
.unwrap_or_default();
let combined = if stderr.is_empty() {
stdout
} else {
format!("{stdout}\n{stderr}")
};
return Some(ProbeResult {
command: cmd.clone(),
passed: status.success(),
@@ -151,7 +177,9 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
timed_out: false,
});
}
Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); }
Ok(None) => {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(_) => return None,
}
};
@@ -180,7 +208,10 @@ pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Optio
///
/// Return: `Some(command)` if a command could be determined, `None` if
/// no marker files matched (e.g. plain Python project with no test dir).
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
fn resolve_verify_command(
probe_dir: &std::path::Path,
override_cmd: Option<&str>,
) -> Option<String> {
if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() {
return Some(cmd.trim().to_string());
@@ -201,18 +232,35 @@ fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
let scripts = v.get("scripts")?;
if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
if scripts
.get("test")
.and_then(|s| s.as_str())
.as_ref()
.is_some_and(|s| !s.is_empty())
{
return Some("npm test 2>&1".to_string());
}
if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
if scripts
.get("build")
.and_then(|s| s.as_str())
.as_ref()
.is_some_and(|s| !s.is_empty())
{
return Some("npm run build 2>&1".to_string());
}
}
return Some("npm test 2>&1".to_string());
}
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
if has_file("pyproject.toml")
|| has_file("requirements.txt")
|| has_file("setup.py")
|| has_file("setup.cfg")
|| has_file("Pipfile")
|| has_file("poetry.lock")
{
if has_file("pyproject.toml") {
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
let content =
std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
if content.contains("[tool.pytest") {
return Some("python -m pytest --tb=short -q 2>&1".to_string());
}
@@ -307,10 +355,7 @@ fn truncate_output(s: &str, max: usize) -> String {
/// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead).
/// Compose the system prompt for the quality-review subagent.
fn compose_review_prompt(
state: &AppStateRest,
probe_note: &str,
) -> String {
fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> String {
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
std::process::Command::new("git")
.arg("diff")
@@ -323,10 +368,15 @@ fn compose_review_prompt(
} else {
String::new()
};
let history_output = if let Some(rt) = &state.session_runtime {
let msgs: Vec<String> = rt.messages.iter()
.filter(|m| m.role == crate::dto::chat::message::Role::Assistant || m.role == crate::dto::chat::message::Role::User)
let msgs: Vec<String> = rt
.messages
.iter()
.filter(|m| {
m.role == crate::dto::chat::message::Role::Assistant
|| m.role == crate::dto::chat::message::Role::User
})
.rev()
.take(10)
.map(|m| format!("{:?}: {}", m.role, m.content.as_deref().unwrap_or("")))
@@ -371,7 +421,6 @@ fn compose_review_prompt(
/// Return: `Ok(())` once the review has been kicked off; errors only
/// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead).
#[allow(clippy::unnecessary_debug_formatting)]
pub fn trigger_review(state: &mut AppStateRest) {
state.misc.lesson_running = true;
@@ -380,17 +429,22 @@ pub fn trigger_review(state: &mut AppStateRest) {
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
if !content.contains("docs/lesson") {
use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore_path) {
let prefix = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" };
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&gitignore_path)
{
let prefix = if content.is_empty() || content.ends_with('\n') {
""
} else {
"\n"
};
let _ = writeln!(file, "{prefix}docs/lesson/");
}
}
}
let mut def = AgentDefinition::new(
"lesson-generator".to_string(),
"reviewer".to_string(),
);
let mut def = AgentDefinition::new("lesson-generator".to_string(), "reviewer".to_string());
// Explicitly allow write_file for docs/lesson
def.allowed_tools = Some(vec![
"read".to_string(),
@@ -402,7 +456,7 @@ pub fn trigger_review(state: &mut AppStateRest) {
let mut ctx = build_subagent_context(&def);
ctx.session_dir.clone_from(&state.session_dir);
ctx.workspaces.clone_from(&state.workspace_roots);
let probe_result = probe_build_test(
&state.workspace_roots,
state.settings.verify_command.as_deref(),
@@ -416,7 +470,10 @@ pub fn trigger_review(state: &mut AppStateRest) {
} else if r.timed_out {
format!("Build/test verification timed out ({}).", r.command)
} else {
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
format!(
"Build/test verification failed ({}). Output: {}",
r.command, r.output
)
}
}
None => "No build/test probe matched.".to_string(),
@@ -432,13 +489,22 @@ pub fn trigger_review(state: &mut AppStateRest) {
let mut rx = rx;
while let Some(event) = rx.blocking_recv() {
match &event {
SubagentEvent::ToolCall { tool, .. } => tracing::debug!("[review] tool call: {}", tool),
SubagentEvent::ToolResult { tool, .. } => tracing::debug!("[review] tool result: {}", tool),
SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[review] tool call: {}", tool)
}
SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[review] tool result: {}", tool)
}
SubagentEvent::StepCompleted { .. } => tracing::trace!("[review] step completed"),
SubagentEvent::StepFailed { step, error } => tracing::warn!("[review] step {} failed: {}", step, error),
SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[review] step {} failed: {}", step, error)
}
SubagentEvent::Progress(_) => {}
SubagentEvent::Completed { .. } => tracing::debug!("[review] completed"),
SubagentEvent::Usage { tokens_in, tokens_out } => {
SubagentEvent::Completed => tracing::debug!("[review] completed"),
SubagentEvent::Usage {
tokens_in,
tokens_out,
} => {
if let Ok(mut q) = turn_events_for_drain.lock() {
q.push_back(TurnEvent::ReviewUsage {
tokens_in: *tokens_in,
@@ -449,7 +515,7 @@ pub fn trigger_review(state: &mut AppStateRest) {
}
}
});
let turn_events = state.turn_events.clone();
std::thread::spawn(move || {
@@ -487,15 +553,18 @@ const STALE_AFTER_DAYS: i64 = 60;
/// `mem.write`.
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
let mut flagged = Vec::new();
let names = MarkdownMemoryRepository::new().list(memory_dir).unwrap_or_default();
let names = MarkdownMemoryRepository::new()
.list(memory_dir)
.unwrap_or_default();
let now = chrono::Utc::now().timestamp_millis();
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
for name in names {
if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
mem.lifecycle = "stale".to_string();
MarkdownMemoryRepository::new().save(memory_dir, &mem)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
MarkdownMemoryRepository::new()
.save(memory_dir, &mem)
.map_err(|e| std::io::Error::other(e.to_string()))?;
flagged.push(name);
}
}
@@ -521,7 +590,11 @@ pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
if !flagged.is_empty() {
state.push_toast(Toast::new(
ToastKind::Info,
format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")),
format!(
"Staleness sweep: {} lesson(s) flagged as stale: {}",
flagged.len(),
flagged.join(", ")
),
));
}
}
@@ -551,7 +624,10 @@ pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson>
/// Write the session's pending-lessons queue to disk as pretty JSON.
///
/// Return: `Ok(())`, or an I/O error from writing the file.
pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
pub fn save_pending_lessons(
session_dir: &std::path::Path,
pending: &[PendingLesson],
) -> std::io::Result<()> {
let path = session_dir.join("pending_lessons.json");
let data = serde_json::to_string_pretty(pending)?;
std::fs::write(&path, data)
@@ -570,7 +646,10 @@ pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLes
///
/// Return: the still-pending lessons (post-commit), or an I/O error from
/// writing memory files or the queue.
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
pub fn process_pending_lessons(
session_dir: &std::path::Path,
memory_dir: &std::path::Path,
) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis();
let grace_window = 5_000;
@@ -599,8 +678,9 @@ pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::
after_snippet: None,
provenances: vec![],
};
MarkdownMemoryRepository::new().save(memory_dir, &mem)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
MarkdownMemoryRepository::new()
.save(memory_dir, &mem)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
save_pending_lessons(session_dir, &remaining)?;
@@ -644,8 +724,9 @@ pub fn resolve_pending_lesson(
after_snippet: None,
provenances: vec![],
};
MarkdownMemoryRepository::new().save(memory_dir, &mem)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
MarkdownMemoryRepository::new()
.save(memory_dir, &mem)
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
} else {
remaining.push(p);
@@ -100,7 +100,6 @@ pub enum Action {
/// need to know how to *produce* actions.
///
/// Return: nothing; `state` is mutated in place.
#[allow(clippy::too_many_lines)]
pub fn apply_action(state: &mut AppStateRest, action: Action) {
match action {
Action::ForceQuit => {
@@ -869,7 +868,7 @@ fn refresh_lesson_counters(memory_dir: &std::path::Path, rt: &mut crate::app::st
/// Flow: if `db` is `Some`, lock the mutex and call `insert_message`.
/// Errors are silently ignored.
fn archive_message(db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>, session_id: &str, msg: &ChatMessage) {
if let Some(arc) = db {
if let Some(arc) = sess.db {
if let Ok(conn) = arc.lock() {
let _ = crate::model::msglog::insert_message(&conn, session_id, msg);
}
@@ -913,7 +912,6 @@ const HIVE_MIND_KICKOFF_NOTE: &str = "The Hive is stirring — Core Intelligence
///
/// Return: `Ok(())` on successful completion, or an error from the LLM
/// API after retries are exhausted.
#[allow(clippy::too_many_lines)]
fn run_agent_turn(
tc: &TurnCtx,
messages: &[ChatMessage],
@@ -1328,9 +1326,11 @@ fn run_agent_turn(
&tool_name,
&tool_call.id,
&args,
&tc_ref.edit_log_session_dir,
&tc_ref.session_id,
tc_ref.db.as_ref(),
&ToolExecSession {
dir: &tc_ref.edit_log_session_dir,
id: &tc_ref.session_id,
db: tc_ref.db.as_ref(),
},
) {
Ok(result) => (result, false, is_edit_tool),
Err(e) => (e.to_string(), true, false),
@@ -1538,28 +1538,31 @@ fn run_agent_turn(
///
/// Return: the tool's stdout string, or an error if no matching tool was
/// found or the tool run itself failed.
#[allow(clippy::too_many_arguments)]
struct ToolExecSession<'a> {
dir: &'a std::path::Path,
id: &'a str,
db: Option<&'a std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
}
fn execute_one_tool(
tools: &[Box<dyn crate::tool::Tool>],
ctx: &crate::tool::ToolCtx,
name: &str,
tool_call_id: &str,
args: &serde_json::Value,
session_dir: &std::path::Path,
session_id: &str,
db: Option<&std::sync::Arc<std::sync::Mutex<rusqlite::Connection>>>,
sess: &ToolExecSession<'_>,
) -> anyhow::Result<String> {
for tool in tools {
if tool.name() == name {
// Snapshot current file content before write/edit for rewind
if (name == "write" || name == "edit") && !tool_call_id.is_empty() {
if let Some(arc) = db {
if let Some(arc) = sess.db {
if let Ok(conn) = arc.lock() {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("");
if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) {
if let Ok(bytes) = std::fs::read(&abs_path) {
let _ = crate::model::msglog::store_blob(
&conn, session_id, tool_call_id, &bytes, None,
&conn, sess.id, tool_call_id, &bytes, None,
);
}
}
@@ -1600,11 +1603,11 @@ fn execute_one_tool(
content_sha256,
bytes_delta,
origin: ctx.origin.tag(),
session_id: session_id.to_string(),
session_id: sess.id.to_string(),
};
let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new();
if let Ok(mut el) = repo.open(session_dir) {
let _ = repo.append(session_dir, &mut el, entry);
if let Ok(mut el) = repo.open(sess.dir) {
let _ = repo.append(sess.dir, &mut el, entry);
}
}
return Ok(result);
@@ -10,7 +10,7 @@
//! `o200k_base` is an approximation for non-OpenAI providers but is far
//! closer than a flat byte-per-token guess; it's only used for the
//! 85%/95% budget thresholds, not for billing-accurate counts.
use crate::dto::chat::message::ChatMessage;
/// Count tokens in a single string under `o200k_base`.
///
@@ -25,20 +25,16 @@ pub fn count_tokens(text: &str) -> usize {
.len()
}
/// Count tokens in a `ChatMessage`'s text content.
///
/// Return: 0 for a message with no `content` (e.g. an assistant message
/// that only carries `tool_calls`).
#[allow(dead_code)]
pub fn count_message_tokens(msg: &ChatMessage) -> usize {
msg.content.as_deref().map_or(0, count_tokens)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::chat::message::ChatMessage;
/// Count tokens in a `ChatMessage`'s text content.
fn count_message_tokens(msg: &ChatMessage) -> usize {
msg.content.as_deref().map_or(0, count_tokens)
}
#[test]
fn empty_string_has_zero_tokens() {
assert_eq!(count_tokens(""), 0);
@@ -43,9 +43,11 @@ mod tests {
temperature: None,
},
);
let mut settings = Settings::default();
settings.provider = "zen".to_string();
settings.model = "deepseek-v4-flash-free".to_string();
let settings = Settings {
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
..Default::default()
};
assert_eq!(resolve(&app_config, &settings), 128_000);
}
@@ -53,9 +55,11 @@ mod tests {
#[test]
fn falls_back_to_default_context_window_when_no_role_matches() {
let app_config = AppConfig::default();
let mut settings = Settings::default();
settings.provider = "nonexistent".to_string();
settings.model = "nonexistent-model".to_string();
let settings = Settings {
provider: "nonexistent".to_string(),
model: "nonexistent-model".to_string(),
..Default::default()
};
assert_eq!(
resolve(&app_config, &settings),
@@ -76,9 +80,11 @@ mod tests {
temperature: None,
},
);
let mut settings = Settings::default();
settings.provider = "zen".to_string();
settings.model = "deepseek-v4-flash-free".to_string();
let settings = Settings {
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
..Default::default()
};
assert_eq!(
resolve(&app_config, &settings),
@@ -2,355 +2,4 @@
//! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done).
pub mod turn;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// One atomic event extracted from an LLM streaming response stream.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum StreamEvent {
Token(String),
Reasoning(String),
ToolCallDelta {
index: usize,
id: Option<String>,
name: Option<String>,
arguments_delta: String,
},
Usage {
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
},
Done,
Error(String),
}
/// Buffered SSE frame parser that accumulates raw `data:` lines and
/// flushes a `StreamEvent` on each blank-line boundary.
pub struct SseParser {
buffer: String,
event_type: Option<String>,
data_lines: Vec<String>,
}
impl SseParser {
/// Create a new parser with an empty buffer.
pub fn new() -> Self {
SseParser {
buffer: String::new(),
event_type: None,
data_lines: Vec::new(),
}
}
/// Feed a raw SSE chunk and produce any completed events.
///
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
/// blank line, call `flush_event` to parse the accumulated data →
/// on `event:` line, store the event type → on `data:` line, append
/// to data accumulator → continue until buffer exhausted.
///
/// Edge case: a chunk may split mid-line; the remainder stays in the
/// buffer for the next `feed()` call.
///
/// Return: all `StreamEvent`s completed by this chunk.
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
self.buffer.push_str(chunk);
let mut events = Vec::new();
while let Some(line_end) = self.buffer.find('\n') {
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
self.buffer = self.buffer[line_end + 1..].to_string();
if line.is_empty() {
events.extend(self.flush_event());
} else if let Some(ty) = line.strip_prefix("event: ") {
self.event_type = Some(ty.trim().to_string());
} else if let Some(data) = line.strip_prefix("data:") {
// Handle both "data: {...}" (with space) and "data:{...}"
// (without space). Some providers omit the trailing space.
let data = data.trim_start().to_string();
self.data_lines.push(data);
}
}
events
}
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
///
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
/// emit `Usage` if a usage object is present → else match `event_type`
/// ("message.stop", "message.delta", etc.) → extract content,
/// reasoning, tool-call deltas, or finish-reason from the delta
/// structure (supporting both Anthropic-style top-level delta and
/// OpenAI-style `choices` array).
///
/// Why: dual-format support in one method avoids a separate
/// provider-specific parsing layer.
///
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
fn flush_event(&mut self) -> Vec<StreamEvent> {
let data = self.data_lines.join("\n");
self.data_lines.clear();
let event_type = self.event_type.take().unwrap_or_default();
if data.is_empty() || data == "[DONE]" {
if data == "[DONE]" {
return vec![StreamEvent::Done];
}
return vec![];
}
let value: Value = match serde_json::from_str(&data) {
Ok(v) => v,
Err(e) => {
tracing::warn!("[stream] failed to parse chunk: {}", e);
return vec![];
}
};
let mut events = Vec::new();
if let Some(usage) = value.get("usage") {
if !usage.is_null() {
let prompt_tokens = usage
.get("prompt_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
0
});
let completion_tokens = usage
.get("completion_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] completion_tokens missing in usage chunk");
0
});
let total_tokens = usage
.get("total_tokens")
.and_then(serde_json::Value::as_u64)
.unwrap_or_else(|| {
tracing::warn!("[stream] total_tokens missing in usage chunk");
prompt_tokens + completion_tokens
});
events.push(StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
});
}
}
let mut other_events = match event_type.as_str() {
"message.stop" => vec![StreamEvent::Done],
"message.delta" | "" => {
let mut d_events = Vec::new();
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
if let Some(choices) = delta.as_array() {
if let Some(choice) = choices.first() {
if let Some(d) = choice.get("delta") {
// Content token
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
}
// Reasoning token
if let Some(reasoning) =
d.get("reasoning_content").and_then(|r| r.as_str())
{
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
}
// Tool calls — iterate ALL entries, not just first()
if let Some(tool_calls) =
d.get("tool_calls").and_then(|tc| tc.as_array())
{
for tc in tool_calls {
let index = tc.get("index").and_then(serde_json::Value::as_u64).unwrap_or_else(|| {
tracing::warn!("[stream] tool call delta missing index, defaulting to 0");
0
}) as usize;
let id = tc
.get("id")
.and_then(|i| i.as_str())
.map(std::string::ToString::to_string);
let name = tc
.get("function")
.and_then(|f| f.get("name"))
.and_then(|n| n.as_str())
.map(std::string::ToString::to_string);
let args_delta = tc
.get("function")
.and_then(|f| f.get("arguments"))
.and_then(|a| a.as_str())
.unwrap_or("")
.to_string();
d_events.push(StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta: args_delta,
});
}
}
// Finish reason
if let Some(reason) =
choice.get("finish_reason").and_then(|r| r.as_str())
{
if reason == "stop" || reason == "tool_calls" {
d_events.push(StreamEvent::Done);
}
}
}
}
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
d_events.push(StreamEvent::Token(content.to_string()));
}
}
d_events
}
_ => vec![],
};
events.append(&mut other_events);
events
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feed_parses_single_token_chunk() {
let mut p = SseParser::new();
let events = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n");
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::Token(t) => assert_eq!(t, "hello"),
other => panic!("expected Token, got {other:?}"),
}
}
#[test]
fn feed_handles_chunk_split_mid_line() {
let mut p = SseParser::new();
let e1 = p.feed("data: {\"choices\":[{\"delta\":{\"content\":\"partial");
assert!(
e1.is_empty(),
"no event until the line and blank separator complete"
);
let e2 = p.feed("\"}}]}\n\n");
assert_eq!(e2.len(), 1);
match &e2[0] {
StreamEvent::Token(t) => assert_eq!(t, "partial"),
other => panic!("expected Token, got {other:?}"),
}
}
#[test]
fn feed_emits_done_on_done_sentinel() {
let mut p = SseParser::new();
let events = p.feed("data: [DONE]\n\n");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::Done));
}
#[test]
fn feed_emits_done_on_finish_reason_stop() {
let mut p = SseParser::new();
let events = p.feed("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::Done));
}
#[test]
fn feed_parses_tool_call_delta() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\"\"}}]}}]}\n\n",
);
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::ToolCallDelta {
index,
id,
name,
arguments_delta,
} => {
assert_eq!(*index, 0);
assert_eq!(id.as_deref(), Some("call_1"));
assert_eq!(name.as_deref(), Some("bash"));
assert_eq!(arguments_delta, "{\"cmd\"");
}
other => panic!("expected ToolCallDelta, got {other:?}"),
}
}
#[test]
fn feed_parses_usage_chunk() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
);
assert_eq!(events.len(), 1);
match &events[0] {
StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
} => {
assert_eq!(*prompt_tokens, 10);
assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15);
}
other => panic!("expected Usage, got {other:?}"),
}
}
#[test]
fn feed_parses_usage_and_content_bundled_chunk() {
let mut p = SseParser::new();
let events = p.feed(
"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":5,\"total_tokens\":15}}\n\n",
);
assert_eq!(events.len(), 2);
match (&events[0], &events[1]) {
(
StreamEvent::Usage {
prompt_tokens,
completion_tokens,
total_tokens,
},
StreamEvent::Token(t),
) => {
assert_eq!(*prompt_tokens, 10);
assert_eq!(*completion_tokens, 5);
assert_eq!(*total_tokens, 15);
assert_eq!(t, "hello");
}
other => panic!("expected [Usage, Token], got {other:?}"),
}
}
#[test]
fn feed_ignores_empty_data_lines() {
let mut p = SseParser::new();
let events = p.feed(": comment\n\n");
assert!(events.is_empty());
}
#[test]
fn feed_multiple_events_across_one_chunk() {
let mut p = SseParser::new();
let chunk = "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n";
let events = p.feed(chunk);
assert_eq!(events.len(), 2);
match (&events[0], &events[1]) {
(StreamEvent::Token(a), StreamEvent::Token(b)) => {
assert_eq!(a, "a");
assert_eq!(b, "b");
}
other => panic!("expected two Tokens, got {other:?}"),
}
}
}
pub use zesdex_entities::{SseParser, StreamEvent};
+40 -15
View File
@@ -1,9 +1,9 @@
//! Application-level "miscellaneous" state: scroll, input buffer,
//! overlay stack, toasts, editor, and autocomplete.
use super::types::Overlay;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::types::Overlay;
/// A shared, async-writable cache of directory entries, used to avoid
/// re-reading a directory every render frame.
@@ -134,7 +134,6 @@ const COMMANDS: &[&str] = &[
"/model",
"/model ls",
"/model add",
"/todo",
"/usage",
"/compact",
@@ -211,7 +210,10 @@ impl InputState {
return None;
}
let boundary_ok = at_pos == 0
|| before_cursor[..at_pos].chars().next_back().is_some_and(char::is_whitespace);
|| before_cursor[..at_pos]
.chars()
.next_back()
.is_some_and(char::is_whitespace);
if !boundary_ok {
return None;
}
@@ -225,8 +227,8 @@ impl InputState {
/// if none, close and return → otherwise fuzzy-match `query` against
/// `files` via `nucleo-matcher`, keep the top 10 by score.
pub fn open_mention_autocomplete(&mut self, files: &[String]) {
use nucleo_matcher::{Config, Matcher};
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;
@@ -234,7 +236,11 @@ impl InputState {
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_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;
@@ -245,11 +251,17 @@ impl InputState {
/// Wraps around at the boundaries.
pub fn cycle_autocomplete(&mut self, forward: bool) {
let n = self.autocomplete_candidates.len();
if n == 0 { return; }
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 };
self.autocomplete_idx = if self.autocomplete_idx == 0 {
n - 1
} else {
self.autocomplete_idx - 1
};
}
}
@@ -261,7 +273,11 @@ impl InputState {
///
/// Return: `true` if a candidate was selected, `false` if none existed.
pub fn select_autocomplete(&mut self) -> bool {
let Some(candidate) = self.autocomplete_candidates.get(self.autocomplete_idx).cloned() else {
let Some(candidate) = self
.autocomplete_candidates
.get(self.autocomplete_idx)
.cloned()
else {
return false;
};
match self.autocomplete_kind {
@@ -282,7 +298,8 @@ impl InputState {
return false;
}
let replacement = format!("@{candidate} ");
self.buffer.replace_range(self.mention_start..self.cursor, &replacement);
self.buffer
.replace_range(self.mention_start..self.cursor, &replacement);
self.cursor = self.mention_start + replacement.len();
}
}
@@ -406,8 +423,6 @@ pub struct MiscState {
pub selected_index: usize,
pub editor: Option<super::super::mode::editor::EditorState>,
pub api_connected: bool,
#[allow(dead_code)]
pub api_context_length: Option<u32>,
pub tick_count: u64,
pub todo_content: String,
pub lesson_running: bool,
@@ -427,7 +442,6 @@ impl MiscState {
selected_index: 0,
editor: None,
api_connected: false,
api_context_length: None,
tick_count: 0,
todo_content: String::new(),
lesson_running: false,
@@ -443,7 +457,12 @@ impl MiscState {
///
/// Return: the expired toasts (after removal).
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
let expired: Vec<_> = self
.toasts
.iter()
.filter(|t| t.expired(now_ms))
.cloned()
.collect();
self.toasts.retain(|t| !t.expired(now_ms));
expired
}
@@ -463,13 +482,19 @@ mod tests {
#[test]
fn mention_at_buffer_start_triggers() {
let input = input_with("@mai", 4);
assert_eq!(input.mention_query_at_cursor(), Some((0, "mai".to_string())));
assert_eq!(
input.mention_query_at_cursor(),
Some((0, "mai".to_string()))
);
}
#[test]
fn mention_after_space_mid_sentence_triggers() {
let input = input_with("look at @read", 13);
assert_eq!(input.mention_query_at_cursor(), Some((8, "read".to_string())));
assert_eq!(
input.mention_query_at_cursor(),
Some((8, "read".to_string()))
);
}
#[test]
+94 -38
View File
@@ -17,12 +17,12 @@ use crate::app::mcp::manager::McpManager;
use crate::app::workflow::engine::WorkflowEngine;
use zesdex_cms::domain::app_config::AppConfig;
use zesdex_cms::domain::edit_log::EditLog;
use zesdex_cms::domain::repository::EditLogRepository;
use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository;
use zesdex_cms::domain::repository::AppConfigRepository;
use zesdex_cms::domain::repository::EditLogRepository;
use zesdex_cms::domain::repository::SettingsRepository;
use zesdex_cms::domain::settings::Settings;
use zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository;
use zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository;
use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository;
/// A single transcript entry rendered in the TUI chat pane.
@@ -51,7 +51,6 @@ impl ChatMessageDisplay {
/// other module.
#[derive(Clone)]
pub struct AppStateRest {
pub settings: Settings,
pub app_config: AppConfig,
pub workspace_roots: Vec<PathBuf>,
@@ -91,7 +90,11 @@ impl AppStateRest {
/// Why: falls back to `memory_dir` itself (with a warning) when it has
/// no parent, and to an empty session id when the dir name can't be
/// read, so construction never fails.
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: &std::path::Path, memory_dir: PathBuf) -> Self {
pub fn new(
workspace_roots: Vec<PathBuf>,
session_dir: &std::path::Path,
memory_dir: PathBuf,
) -> Self {
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
let settings = JsonSettingsRepository::new()
.load(&store_base_dir)
@@ -99,18 +102,27 @@ impl AppStateRest {
let app_config = JsonAppConfigRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let worktrees_dir = memory_dir.parent().unwrap_or_else(|| {
tracing::warn!("[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display());
&memory_dir
}).join("worktrees");
let worktrees_dir = memory_dir
.parent()
.unwrap_or_else(|| {
tracing::warn!(
"[state] memory_dir '{}' has no parent, using it for worktrees",
memory_dir.display()
);
&memory_dir
})
.join("worktrees");
let dir_cache = DirCache::new();
let session_id = session_dir
.file_name().map_or_else(|| {
tracing::warn!("[state] session_dir has no file_name component, using empty session_id");
let session_id = session_dir.file_name().map_or_else(
|| {
tracing::warn!(
"[state] session_dir has no file_name component, using empty session_id"
);
String::new()
}, |n| n.to_string_lossy().to_string());
},
|n| n.to_string_lossy().to_string(),
);
let mut state = AppStateRest {
settings,
app_config,
workspace_roots,
@@ -123,10 +135,15 @@ impl AppStateRest {
abort_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
dir_cache: Arc::new(RwLock::new(dir_cache)),
mention_index: MentionIndex::new(),
edit_log: JsonlEditLogRepository::new().open(session_dir).unwrap_or_else(|e| {
tracing::warn!("[state] failed to open edit log at '{}': {e}", session_dir.display());
EditLog::new()
}),
edit_log: JsonlEditLogRepository::new()
.open(session_dir)
.unwrap_or_else(|e| {
tracing::warn!(
"[state] failed to open edit log at '{}': {e}",
session_dir.display()
);
EditLog::new()
}),
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
workflow_engine: WorkflowEngine::new(),
mcp_manager: McpManager::new(),
@@ -149,7 +166,9 @@ impl AppStateRest {
let mut hasher = sha2::Sha256::new();
hasher.update(abs_root.to_string_lossy().as_bytes());
let hash_hex = hex::encode(hasher.finalize());
let folder_name = abs_root.file_name().map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
let folder_name = abs_root
.file_name()
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
let history_dir = base_dir.join("history");
let _ = std::fs::create_dir_all(&history_dir);
@@ -194,9 +213,14 @@ impl AppStateRest {
}
// Wrap the msg_queue in a static-lifetime closure for use as ProgressFn.
let progress: provisioner::ProgressFn = Some(&|msg: &str| push_msg(&msg_queue, msg));
let progress: provisioner::ProgressFn =
Some(&|msg: &str| push_msg(&msg_queue, msg));
let report = |msg: &str| { if let Some(f) = &progress { f(msg); }};
let report = |msg: &str| {
if let Some(f) = &progress {
f(msg);
}
};
report("LSP: provisioning servers...");
let results = provisioner::provision_all_with_progress(progress);
@@ -204,18 +228,29 @@ impl AppStateRest {
let connected = provisioner::auto_connect(&lsp_mgr, &results);
for name in &connected {
tracing::info!("LSP: {} connected", name);
let m = format!("LSP: {name} connected ✓"); push_msg(&msg_queue, &m);
let m = format!("LSP: {name} connected ✓");
push_msg(&msg_queue, &m);
}
for r in &results {
if let ProvisionResult::Failed { language, server_name, reason, .. } = r {
if let ProvisionResult::Failed {
language,
server_name,
reason,
..
} = r
{
tracing::warn!("LSP {} ({}): {}", server_name, language, reason);
let m = format!("LSP: {server_name} ({language}) ✗ - {reason}"); push_msg(&msg_queue, &m);
let m = format!("LSP: {server_name} ({language}) ✗ - {reason}");
push_msg(&msg_queue, &m);
}
}
if connected.is_empty() {
let m = "LSP: no servers available — install manually or check prerequisites".to_string(); push_msg(&msg_queue, &m);
let m = "LSP: no servers available — install manually or check prerequisites"
.to_string();
push_msg(&msg_queue, &m);
} else {
let m = format!("LSP: {} server(s) connected", connected.len()); push_msg(&msg_queue, &m);
let m = format!("LSP: {} server(s) connected", connected.len());
push_msg(&msg_queue, &m);
}
});
}
@@ -259,7 +294,11 @@ impl AppStateRest {
}
let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
let rel_str = rel.display().to_string();
let formatted = if i == 0 { rel_str } else { format!("[{i}]{rel_str}") };
let formatted = if i == 0 {
rel_str
} else {
format!("[{i}]{rel_str}")
};
paths.push(formatted);
if paths.len() >= MAX_MENTION_ENTRIES {
break 'roots;
@@ -275,10 +314,13 @@ impl AppStateRest {
/// Return: `false` (and logs a warning) if the mutex is poisoned, rather
/// than propagating a panic.
pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight.lock().map_or_else(|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned");
false
}, |g| *g)
self.turn_in_flight.lock().map_or_else(
|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned");
false
},
|g| *g,
)
}
/// Shut down every running LSP server process.
@@ -317,14 +359,28 @@ impl AppStateRest {
/// `session_dir` itself -- logging a warning at each step down, so this
/// never fails even on a shallow path.
pub fn store_base_dir(&self) -> std::path::PathBuf {
self.session_dir.parent()
.and_then(|p| p.parent()).map_or_else(|| {
tracing::warn!("[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display());
self.session_dir.parent().map_or_else(|| {
tracing::warn!("[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display());
self.session_dir.clone()
}, std::path::Path::to_path_buf)
}, std::path::Path::to_path_buf)
self.session_dir
.parent()
.and_then(|p| p.parent())
.map_or_else(
|| {
tracing::warn!(
"[state] session_dir '{}' has no grandparent, using parent",
self.session_dir.display()
);
self.session_dir.parent().map_or_else(
|| {
tracing::warn!(
"[state] session_dir '{}' has no parent at all, using itself",
self.session_dir.display()
);
self.session_dir.clone()
},
std::path::Path::to_path_buf,
)
},
std::path::Path::to_path_buf,
)
}
/// Build a `ToolCtx` for tool calls originating from the main agent.
+2 -24
View File
@@ -4,20 +4,7 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Cumulative token/latency counters for a session, persisted alongside it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub tokens_in: u64,
pub tokens_out: u64,
#[serde(default)]
pub last_tokens_in: u64,
#[serde(default)]
pub last_tokens_out: u64,
pub api_calls: u64,
pub review_tokens: u64,
pub total_ms: u64,
}
pub use zesdex_entities::seaorm::common::usage::UsageStats;
/// Mutable, serializable state for one session: chat history, tool
/// results, pending tools, background jobs, and lesson/review counters
/// shown in the TUI status bar.
@@ -55,16 +42,7 @@ pub struct SessionRuntime {
pub hive_mind_converged: bool,
}
/// Record of one completed tool invocation, kept for transcript/history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResult {
pub tool_call_id: String,
pub tool_name: String,
pub output: String,
pub is_error: bool,
pub duration_ms: u64,
}
pub use zesdex_entities::seaorm::common::tool_result::ToolCallResult;
/// A tool call awaiting execution, along with which execution model
/// (inline, deferred, async) it should run under.
#[derive(Debug, Clone, Serialize, Deserialize)]
+179 -75
View File
@@ -7,14 +7,14 @@
//! bash exfiltration and destructive-pattern detection) so that subagents
//! are not a weaker link than the main agent.
use std::fmt::Write;
use sha2::Digest;
use tokio::sync::mpsc;
use super::context::SubagentContext;
use super::event::SubagentEvent;
use crate::dto::chat::message::ChatMessage;
use crate::dto::provider::request::ToolDef;
use crate::tool::{all_tools, tool_defs, tool_is_risky};
use super::context::SubagentContext;
use super::event::SubagentEvent;
use sha2::Digest;
use std::fmt::Write;
use tokio::sync::mpsc;
use zesdex_cms::domain::repository::AppConfigRepository;
use zesdex_cms::domain::repository::EditLogRepository;
use zesdex_cms::domain::repository::SettingsRepository;
@@ -29,7 +29,9 @@ use zesdex_cms::domain::repository::SettingsRepository;
/// `build_subagent_context`'s default for non-reviewer roles).
///
/// Return: `(tool impls, schema defs)` for the subagent to use.
fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
fn build_subagent_tools(
allowed_tools: &[String],
) -> (Vec<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
let all = all_tools();
let filtered: Vec<Box<dyn crate::tool::Tool>> = if allowed_tools.is_empty() {
all.into_iter()
@@ -62,28 +64,44 @@ fn build_subagent_tools(allowed_tools: &[String]) -> (Vec<Box<dyn crate::tool::T
/// for this before issuing requests (see `run_subagent`).
fn resolve_provider_config() -> (String, String, Option<String>, String) {
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let app_config = zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let settings =
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let app_config =
zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let mut api_key = settings.api_keys.get(&settings.provider).cloned().unwrap_or_else(|| {
tracing::warn!("[subagent] no API key for provider '{}' in settings, trying env/default", settings.provider);
String::new()
});
let mut api_key = settings
.api_keys
.get(&settings.provider)
.cloned()
.unwrap_or_else(|| {
tracing::warn!(
"[subagent] no API key for provider '{}' in settings, trying env/default",
settings.provider
);
String::new()
});
let model = settings.model.clone();
let base_url = app_config.providers.get(&settings.provider)
let base_url = app_config
.providers
.get(&settings.provider)
.map(|p| p.api_base.clone());
if api_key.is_empty() {
if let Some(provider_cfg) = app_config.providers.get(&settings.provider) {
api_key = provider_cfg.api_key_env.as_ref()
api_key = provider_cfg
.api_key_env
.as_ref()
.and_then(|env| std::env::var(env).ok())
.or_else(|| provider_cfg.default_api_key.clone())
.unwrap_or_else(|| {
tracing::warn!("[subagent] all API key resolution paths exhausted for '{}'", settings.provider);
tracing::warn!(
"[subagent] all API key resolution paths exhausted for '{}'",
settings.provider
);
String::new()
});
}
@@ -109,43 +127,82 @@ fn require_api_key(api_key: &str, provider: &str) -> anyhow::Result<()> {
// ─── Subagent-level tool gating (mirrors Harness checks) ───
const STUB_PATTERNS: &[&str] = &[
"todo!()", "todo!(",
"unimplemented!()", "unimplemented!(",
"FIXME", "fixme:", "XXX:", "PLACEHOLDER",
"REPLACE_ME", "stub_value", "stub_function",
"fake_response", "fake_data",
"not implemented", "not yet implemented",
"to be implemented", "to be done",
"todo!()",
"todo!(",
"unimplemented!()",
"unimplemented!(",
"FIXME",
"fixme:",
"XXX:",
"PLACEHOLDER",
"REPLACE_ME",
"stub_value",
"stub_function",
"fake_response",
"fake_data",
"not implemented",
"not yet implemented",
"to be implemented",
"to be done",
];
const DENIAL_PATTERNS: &[&str] = &[
"// skip", "// skipping", "// skipping for now",
"// for now just", "// punt", "// hack:",
"// workaround:", "// cba", "// later",
"// do later", "// ignore for now", "// disable",
"// bypass", "// quick fix", "// temp fix",
"// temporary fix", "// temp:", "// temporary:",
"// skip",
"// skipping",
"// skipping for now",
"// for now just",
"// punt",
"// hack:",
"// workaround:",
"// cba",
"// later",
"// do later",
"// ignore for now",
"// disable",
"// bypass",
"// quick fix",
"// temp fix",
"// temporary fix",
"// temp:",
"// temporary:",
"// noop",
];
const ASSUMPTION_PATTERNS: &[&str] = &[
"// assume", "// probably", "// guess",
"// should work", "// hopefully", "// i think",
"// should be fine", "// likely",
"// assume",
"// probably",
"// guess",
"// should work",
"// hopefully",
"// i think",
"// should be fine",
"// likely",
];
const EXFIL_PATTERNS: &[&str] = &[
"curl ", "wget ", "nc -e ", "ncat ", "/dev/tcp/",
"base64 -d |", "base64 --decode |",
"openssl s_client", "ssh -R ",
"scp /", "rsync /",
"curl ",
"wget ",
"nc -e ",
"ncat ",
"/dev/tcp/",
"base64 -d |",
"base64 --decode |",
"openssl s_client",
"ssh -R ",
"scp /",
"rsync /",
];
const SENSITIVE_PATH_PATTERNS: &[&str] = &[
".ssh/id_rsa", ".ssh/id_ed25519",
".aws/credentials", ".aws/config",
".kube/config", ".docker/config.json",
"/etc/shadow", "/etc/passwd", "/proc/self/environ",
".ssh/id_rsa",
".ssh/id_ed25519",
".aws/credentials",
".aws/config",
".kube/config",
".docker/config.json",
"/etc/shadow",
"/etc/passwd",
"/proc/self/environ",
];
const MIN_REASON_LEN: usize = 8;
@@ -157,10 +214,7 @@ const MIN_REASON_LEN: usize = 8;
/// assumption language, bash exfiltration, destructive commands, sensitive
/// path reads — regardless of the allowed-tools list. Tools that are not
/// risky only get the basic allowlist check.
fn gate_subagent_tool_call(
tool_name: &str,
args: &serde_json::Value,
) -> Option<String> {
fn gate_subagent_tool_call(tool_name: &str, args: &serde_json::Value) -> Option<String> {
// File-mutating tools: write / edit / delete
if matches!(tool_name, "write" | "edit" | "delete") {
if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
@@ -204,10 +258,16 @@ fn gate_subagent_tool_call(
return Some("content contains stub/placeholder pattern; production code must be fully implemented".to_string());
}
if contains_any(content, DENIAL_PATTERNS) {
return Some("content contains denial/punt pattern; implement properly instead of skipping".to_string());
return Some(
"content contains denial/punt pattern; implement properly instead of skipping"
.to_string(),
);
}
if contains_any(content, ASSUMPTION_PATTERNS) {
return Some("content contains assumption pattern; verify against data instead of guessing".to_string());
return Some(
"content contains assumption pattern; verify against data instead of guessing"
.to_string(),
);
}
}
@@ -231,7 +291,9 @@ fn gate_subagent_tool_call(
if !is_standard {
for pat in EXFIL_PATTERNS {
if cmd.contains(pat) {
return Some(format!("potential data-exfiltration command blocked (matched '{pat}')"));
return Some(format!(
"potential data-exfiltration command blocked (matched '{pat}')"
));
}
}
}
@@ -240,9 +302,21 @@ fn gate_subagent_tool_call(
return Some(format!("refused to read/write sensitive path '{pat}'"));
}
}
let dangerous = ["rm -rf /", "rm -rf --no-preserve-root", "rm -rf ~",
"rm -fr /", "mkfs.", "dd if=", ":(){", "> /dev/sda",
"chmod -R 000 /", "shutdown ", "poweroff ", "reboot ", "halt "];
let dangerous = [
"rm -rf /",
"rm -rf --no-preserve-root",
"rm -rf ~",
"rm -fr /",
"mkfs.",
"dd if=",
":(){",
"> /dev/sda",
"chmod -R 000 /",
"shutdown ",
"poweroff ",
"reboot ",
"halt ",
];
for pat in &dangerous {
if cmd.contains(pat) {
return Some(format!("destructive command pattern blocked: {pat}"));
@@ -289,7 +363,9 @@ fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String {
for entry in walker.flatten() {
let path = entry.path();
if let Ok(rel) = path.strip_prefix(root) {
if rel.as_os_str().is_empty() { continue; }
if rel.as_os_str().is_empty() {
continue;
}
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
let prefix = if is_dir { "[DIR] " } else { " " };
writeln!(out, " {}{}", prefix, rel.display()).unwrap();
@@ -331,8 +407,10 @@ fn format_subagent_progress(prefix: &str, text: &str) -> String {
///
/// Return: the concatenated text output, or an `anyhow::Error` if the LLM
/// call fails at any step.
#[allow(clippy::too_many_lines)]
pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) -> anyhow::Result<String> {
pub fn run_subagent(
ctx: &SubagentContext,
tx: &mpsc::Sender<SubagentEvent>,
) -> anyhow::Result<String> {
let mut output = String::new();
let mut messages: Vec<ChatMessage> = Vec::new();
@@ -370,17 +448,23 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
// surfaces past a buried WARN log.
if let Err(error) = require_api_key(&api_key, &provider) {
let error = error.to_string();
let _ = tx.blocking_send(SubagentEvent::StepFailed { step: 0, error: error.clone() });
let _ = tx.blocking_send(SubagentEvent::StepFailed {
step: 0,
error: error.clone(),
});
anyhow::bail!(error);
}
let client = crate::service::provider::LlmClient::new(api_key, model, base_url);
for step in 0..ctx.max_steps {
// Check abort flag before each LLM call so a stuck subagent can
// be cancelled from the parent (mirrors main agent behaviour).
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
if ctx
.abort_flag
.as_ref()
.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
{
let _ = tx.blocking_send(SubagentEvent::StepFailed {
step,
error: "subagent aborted by parent".to_string(),
@@ -403,7 +487,11 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
Some(4096),
|event| -> bool {
// Check abort on every SSE event for responsive cancellation.
if ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst)) {
if ctx
.abort_flag
.as_ref()
.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
{
return false; // signals provider to abort
}
match event {
@@ -417,7 +505,11 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
let prog = format_subagent_progress("replying", &current_token);
let _ = tx_clone.blocking_send(SubagentEvent::Progress(prog));
}
crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => {
crate::app::runtime::stream::StreamEvent::Usage {
prompt_tokens,
completion_tokens,
..
} => {
// Capture usage so the drain thread can route it
// to the parent's `UsageStats::review_tokens`.
// Last writer wins — providers send exactly one
@@ -433,7 +525,10 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
let (response, returned_usage) = match stream_result {
Ok(result) => result,
Err(e) => {
let is_abort = ctx.abort_flag.as_ref().is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
let is_abort = ctx
.abort_flag
.as_ref()
.is_some_and(|f| f.load(std::sync::atomic::Ordering::SeqCst))
|| e.to_string().contains("aborted");
let _ = tx.blocking_send(SubagentEvent::StepFailed {
step,
@@ -459,7 +554,8 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
// subagent never tells the parent about the tokens consumed.
let (mut tok_in, mut tok_out) = returned_usage.unwrap_or((0, 0));
if tok_in == 0 {
let prompt_chars: usize = messages.iter()
let prompt_chars: usize = messages
.iter()
.filter_map(|m| m.content.as_deref())
.map(str::len)
.sum();
@@ -475,7 +571,10 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
});
let has_tool_calls = response.tool_calls.is_some()
&& response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty());
&& response
.tool_calls
.as_ref()
.is_some_and(|tc| !tc.is_empty());
let content = response.content.clone().unwrap_or_default();
@@ -608,7 +707,8 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
for (tool_call, result) in results_vec {
let tool_name = &tool_call.function.name;
let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let args =
crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments);
let _ = tx.blocking_send(SubagentEvent::ToolCall {
tool: tool_name.clone(),
@@ -617,24 +717,28 @@ pub fn run_subagent(ctx: &SubagentContext, tx: &mpsc::Sender<SubagentEvent>) ->
match result {
Ok(output_text) => {
messages.push(ChatMessage::tool_result(tool_call.id.clone(), output_text.clone()));
messages.push(ChatMessage::tool_result(
tool_call.id.clone(),
output_text.clone(),
));
let _ = tx.blocking_send(SubagentEvent::ToolResult {
tool: tool_name.clone(),
args: args.clone(),
});
let is_readonly = tool_name == "read"
|| tool_name == "view_file"
|| tool_name == "grep"
|| tool_name == "grep_search"
|| tool_name == "glob"
|| tool_name == "dir_list"
let is_readonly = tool_name == "read"
|| tool_name == "view_file"
|| tool_name == "grep"
|| tool_name == "grep_search"
|| tool_name == "glob"
|| tool_name == "dir_list"
|| tool_name == "list_dir";
if is_readonly {
if let Some(ref findings) = ctx.workflow_findings {
if let Ok(mut f) = findings.lock() {
let args_json = serde_json::to_string(&args).unwrap_or_default();
let args_json =
serde_json::to_string(&args).unwrap_or_default();
let mut shared_text = output_text;
if shared_text.len() > 50_000 {
shared_text.truncate(50_000);
@@ -41,16 +41,9 @@ impl AgentDefinition {
}
/// Builder method: set the maximum step count for this agent.
#[allow(dead_code)]
pub fn with_max_steps(mut self, steps: usize) -> Self {
self.max_steps = Some(steps);
self
}
/// Builder method: set the temperature for this agent.
#[allow(dead_code)]
pub fn with_temperature(mut self, temp: f32) -> Self {
self.temperature = Some(temp);
self
}
}
@@ -7,9 +7,9 @@
//! Core Intelligence can omit or reshape — and always runs after any
//! hive-mind convergence completes.
use crate::app::workflow::hive_mind::NodeReport;
use zesdex_cms::domain::memory::Memory;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use zesdex_cms::domain::memory::Memory;
/// Write a markdown report of one hive-mind convergence to
/// `<workspace_root>/docs/runs/<timestamp>-<slug>.md`.
+150 -141
View File
@@ -210,20 +210,23 @@ fn format_tool_call_progress(prefix: &str, tool: &str, args: &serde_json::Value)
/// a stuck stage from blocking the entire pipeline forever.
///
/// Return: the agent's text output, or an error on failure.
fn spawn_single_agent(
agent_id: &str,
agent_name: &str,
prompt: &str,
role: &str,
allowed_tools: Option<Vec<String>>,
findings_snapshot: &[String],
findings: &Arc<Mutex<Vec<String>>>,
abort_flag: &Option<Arc<AtomicBool>>,
live: Option<&LiveStateFn>,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
timeout_ms: Option<u64>,
) -> anyhow::Result<String> {
/// Bundled context for spawning a single subagent.
pub(crate) struct SpawnCtx<'a> {
pub agent_id: &'a str,
pub agent_name: &'a str,
pub prompt: &'a str,
pub role: &'a str,
pub allowed_tools: Option<Vec<String>>,
pub findings_snapshot: &'a [String],
pub findings: &'a Arc<Mutex<Vec<String>>>,
pub abort_flag: &'a Option<Arc<AtomicBool>>,
pub live: Option<&'a LiveStateFn>,
pub session_dir: &'a std::path::Path,
pub workspaces: &'a [std::path::PathBuf],
pub timeout_ms: Option<u64>,
}
fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result<String> {
use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
@@ -233,10 +236,10 @@ fn spawn_single_agent(
// Notify UI: this agent is now running.
// Pass both the unique agent_id (UUID for stable key) and agent_name
// (human-readable display name, e.g. a hive-mind node designation).
if let Some(f) = live {
if let Some(f) = &sp.live {
f(
agent_id.to_string(),
agent_name.to_string(),
sp.agent_id.to_string(),
sp.agent_name.to_string(),
AgentStatus {
state: AgentState::Running,
started_at: Some(started_at),
@@ -247,20 +250,20 @@ fn spawn_single_agent(
);
}
let mut def = AgentDefinition::new(agent_name.to_string(), role.to_string());
if let Some(tools) = allowed_tools {
def = def.with_allowed_tools(tools);
let mut def = AgentDefinition::new(sp.agent_name.to_string(), sp.role.to_string());
if let Some(tools) = &sp.allowed_tools {
def = def.with_allowed_tools(tools.clone());
}
let mut ctx = build_subagent_context(&def);
ctx.session_dir = session_dir.to_path_buf();
ctx.workspaces = workspaces.to_vec();
ctx.session_dir = sp.session_dir.to_path_buf();
ctx.workspaces = sp.workspaces.to_vec();
let findings_section = if findings_snapshot.is_empty() {
let findings_section = if sp.findings_snapshot.is_empty() {
String::new()
} else {
format!(
"\n\nFindings from sibling drones in this Hive run:\n{}",
findings_snapshot
sp.findings_snapshot
.iter()
.enumerate()
.map(|(i, f)| format!("{}. {}", i + 1, f))
@@ -269,20 +272,20 @@ fn spawn_single_agent(
)
};
ctx.system_prompt = format!("{prompt}{findings_section}");
ctx.system_prompt = format!("{}{}", sp.prompt, findings_section);
// Link the shared findings Arc so note_finding calls within this
// subagent write into the same vec visible to sibling agents.
ctx.workflow_findings = Some(findings.clone());
ctx.abort_flag.clone_from(abort_flag);
ctx.workflow_findings = Some(sp.findings.clone());
ctx.abort_flag.clone_from(sp.abort_flag);
// Create an mpsc channel and drain events in a background thread.
// The drain thread also pushes intra-division progress updates to the
// live callback (current tool being executed), so the TUI panel shows
// real-time "editing X" or "running build" instead of just "Running…".
let (tx, rx) = tokio::sync::mpsc::channel(64);
let drain_agent_id = agent_id.to_string();
let drain_agent_name = agent_name.to_string();
let drain_live = live.cloned();
let drain_agent_id = sp.agent_id.to_string();
let drain_agent_name = sp.agent_name.to_string();
let drain_live = sp.live.cloned();
let drain_started_at = started_at;
let _drain_thread = std::thread::spawn(move || {
use crate::app::subagent::event::SubagentEvent;
@@ -380,11 +383,12 @@ fn spawn_single_agent(
});
// Check abort before even starting the subagent.
if abort_flag
if sp
.abort_flag
.as_ref()
.is_some_and(|f| f.load(Ordering::SeqCst))
{
anyhow::bail!("subagent '{agent_name}' aborted before start");
anyhow::bail!("subagent '{}' aborted before start", sp.agent_name);
}
// Run subagent on a separate thread so the abort flag can be polled.
@@ -393,14 +397,14 @@ fn spawn_single_agent(
let (done_tx, done_rx) = std::sync::mpsc::channel::<anyhow::Result<String>>();
let bg_ctx = ctx;
let bg_tx = tx;
let bg_name = agent_name.to_string();
let bg_abort = abort_flag.clone();
let bg_name = sp.agent_name.to_string();
let bg_abort = sp.abort_flag.clone();
std::thread::spawn(move || {
let _ = done_tx.send(run_subagent(&bg_ctx, &bg_tx));
});
let poll_interval = Duration::from_millis(200);
let result = if let Some(timeout) = timeout_ms {
let result = if let Some(timeout) = sp.timeout_ms {
let deadline = Duration::from_millis(timeout);
let mut elapsed = Duration::ZERO;
loop {
@@ -431,7 +435,7 @@ fn spawn_single_agent(
let completed_at = chrono::Utc::now().timestamp_millis();
// Notify UI: agent completed or failed
if let Some(f) = live {
if let Some(f) = &sp.live {
let summary_from = |text: &str| {
text.lines()
.next()
@@ -444,8 +448,8 @@ fn spawn_single_agent(
Ok(text) => {
let summary = summary_from(text);
f(
agent_id.to_string(),
agent_name.to_string(),
sp.agent_id.to_string(),
sp.agent_name.to_string(),
AgentStatus {
state: AgentState::Completed,
started_at: Some(started_at),
@@ -457,8 +461,8 @@ fn spawn_single_agent(
}
Err(e) => {
f(
agent_id.to_string(),
agent_name.to_string(),
sp.agent_id.to_string(),
sp.agent_name.to_string(),
AgentStatus {
state: AgentState::Failed,
started_at: Some(started_at),
@@ -476,6 +480,20 @@ fn spawn_single_agent(
type ParallelResult = (usize, anyhow::Result<Vec<String>>);
/// Bundled context for executing a script primitive.
pub(crate) struct PrimitiveCtx<'a> {
pub primitive: &'a ScriptPrimitive,
pub args: &'a HashMap<String, String>,
pub concurrency_cap: usize,
pub continue_on_error: bool,
pub abort_flag: &'a Option<Arc<AtomicBool>>,
pub live: Option<&'a LiveStateFn>,
pub session_dir: &'a std::path::Path,
pub workspaces: &'a [std::path::PathBuf],
pub findings: &'a Arc<Mutex<Vec<String>>>,
pub timeout_ms: Option<u64>,
}
/// Recursively execute a `ScriptPrimitive` tree, respecting an overall
/// concurrency cap for parallel branches.
///
@@ -497,22 +515,11 @@ type ParallelResult = (usize, anyhow::Result<Vec<String>>);
///
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
/// the order they were submitted.
pub fn execute_primitive(
primitive: &ScriptPrimitive,
args: &HashMap<String, String>,
concurrency_cap: usize,
continue_on_error: bool,
abort_flag: &Option<Arc<AtomicBool>>,
live: Option<&LiveStateFn>,
session_dir: &std::path::Path,
workspaces: &[std::path::PathBuf],
findings: &Arc<Mutex<Vec<String>>>,
timeout_ms: Option<u64>,
) -> anyhow::Result<Vec<String>> {
match primitive {
pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
match pc.primitive {
ScriptPrimitive::Agent(prompt) => {
let mut resolved_args = args.clone();
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
let mut resolved_args = pc.args.clone();
let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default();
if !resolved_args.contains_key("findings") {
let formatted_findings = if findings_snapshot.is_empty() {
"None".to_string()
@@ -529,23 +536,23 @@ pub fn execute_primitive(
let resolved = resolve_template(prompt, &resolved_args);
let agent_id = uuid::Uuid::new_v4().to_string();
let agent_name = resolved.chars().take(40).collect::<String>();
match spawn_single_agent(
&agent_id,
&agent_name,
&resolved,
"coder",
None,
&findings_snapshot,
findings,
abort_flag,
live,
session_dir,
workspaces,
timeout_ms,
) {
match spawn_single_agent(SpawnCtx {
agent_id: &agent_id,
agent_name: &agent_name,
prompt: &resolved,
role: "coder",
allowed_tools: None,
findings_snapshot: &findings_snapshot,
findings: pc.findings,
abort_flag: pc.abort_flag,
live: pc.live,
session_dir: pc.session_dir,
workspaces: pc.workspaces,
timeout_ms: pc.timeout_ms,
}) {
Ok(text) => Ok(vec![text]),
Err(e) => {
if continue_on_error {
if pc.continue_on_error {
Ok(vec![format!("agent error: {}", e)])
} else {
Err(e)
@@ -559,8 +566,8 @@ pub fn execute_primitive(
node_id,
tool_scope,
} => {
let mut resolved_args = args.clone();
let findings_snapshot = findings.lock().map(|f| f.clone()).unwrap_or_default();
let mut resolved_args = pc.args.clone();
let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default();
if !resolved_args.contains_key("findings") {
let formatted_findings = if findings_snapshot.is_empty() {
"None".to_string()
@@ -580,20 +587,20 @@ pub fn execute_primitive(
tracing::debug!("[hive] deploying drone {node_id}: {truncated}");
let agent_name = format!("{node_id}: {truncated}");
let allowed_tools = crate::app::subagent::division::tool_scope::tools_for(tool_scope);
match spawn_single_agent(
&agent_id,
&agent_name,
&resolved,
node_id,
Some(allowed_tools),
&findings_snapshot,
findings,
abort_flag,
live,
session_dir,
workspaces,
timeout_ms,
) {
match spawn_single_agent(SpawnCtx {
agent_id: &agent_id,
agent_name: &agent_name,
prompt: &resolved,
role: node_id,
allowed_tools: Some(allowed_tools),
findings_snapshot: &findings_snapshot,
findings: pc.findings,
abort_flag: pc.abort_flag,
live: pc.live,
session_dir: pc.session_dir,
workspaces: pc.workspaces,
timeout_ms: pc.timeout_ms,
}) {
Ok(text) => {
tracing::debug!(
"[hive] drone {node_id} completed — merging into collective state"
@@ -604,14 +611,14 @@ pub fn execute_primitive(
// still running (via read_findings) or any drone spawned
// afterward sees this immediately, making the collective
// state genuinely continuous rather than batch-synced.
if let Ok(mut f) = findings.lock() {
if let Ok(mut f) = pc.findings.lock() {
f.push(format!("[{node_id}]: {text}"));
}
Ok(vec![text])
}
Err(e) => {
tracing::warn!("[hive] drone {node_id} failed: {e}");
if continue_on_error {
if pc.continue_on_error {
Ok(vec![format!("drone error: {}", e)])
} else {
Err(e)
@@ -626,7 +633,7 @@ pub fn execute_primitive(
// independent subagents work simultaneously.
// Each branch shares the same `findings` Arc so note_finding
// calls within any branch are visible to all other branches.
let semaphore = Arc::new(Semaphore::new(concurrency_cap.max(1)));
let semaphore = Arc::new(Semaphore::new(pc.concurrency_cap.max(1)));
let results: Arc<Mutex<Vec<ParallelResult>>> = Arc::new(Mutex::new(Vec::new()));
let handles: Vec<_> = scripts
@@ -634,31 +641,32 @@ pub fn execute_primitive(
.enumerate()
.map(|(idx, script)| {
let script = script.clone();
let args = args.clone();
let args = pc.args.clone();
let sem = Arc::clone(&semaphore);
let results = Arc::clone(&results);
let cap = concurrency_cap;
let abort = abort_flag.clone();
let live_clone = live.cloned();
let session_dir = session_dir.to_path_buf();
let workspaces = workspaces.to_vec();
let findings = Arc::clone(findings);
let to = timeout_ms;
let cap = pc.concurrency_cap;
let continue_on_error = pc.continue_on_error;
let abort = pc.abort_flag.clone();
let live_clone = pc.live.cloned();
let session_dir = pc.session_dir.to_path_buf();
let workspaces = pc.workspaces.to_vec();
let findings = Arc::clone(pc.findings);
let to = pc.timeout_ms;
std::thread::spawn(move || {
let _permit = sem.acquire();
let result = execute_primitive(
&script,
&args,
cap,
let result = execute_primitive(PrimitiveCtx {
primitive: &script,
args: &args,
concurrency_cap: cap,
continue_on_error,
&abort,
live_clone.as_ref(),
&session_dir,
&workspaces,
&findings,
to,
);
abort_flag: &abort,
live: live_clone.as_ref(),
session_dir: &session_dir,
workspaces: &workspaces,
findings: &findings,
timeout_ms: to,
});
if let Ok(mut locked) = results.lock() {
locked.push((idx, result));
}
@@ -699,31 +707,32 @@ pub fn execute_primitive(
for (idx, script) in scripts.iter().enumerate() {
// Check abort before each pipeline stage so we don't
// launch the next division after the user cancelled.
if abort_flag
if pc
.abort_flag
.as_ref()
.is_some_and(|f| f.load(Ordering::SeqCst))
{
if continue_on_error {
if pc.continue_on_error {
all.push(format!("pipeline aborted at stage {idx}"));
break;
}
anyhow::bail!("pipeline aborted by user at stage {idx}");
}
match execute_primitive(
script,
args,
concurrency_cap,
continue_on_error,
abort_flag,
live,
session_dir,
workspaces,
findings,
timeout_ms,
) {
match execute_primitive(PrimitiveCtx {
primitive: script,
args: pc.args,
concurrency_cap: pc.concurrency_cap,
continue_on_error: pc.continue_on_error,
abort_flag: pc.abort_flag,
live: pc.live,
session_dir: pc.session_dir,
workspaces: pc.workspaces,
findings: pc.findings,
timeout_ms: pc.timeout_ms,
}) {
Ok(outputs) => all.extend(outputs),
Err(e) => {
if continue_on_error {
if pc.continue_on_error {
all.push(format!("pipeline stage {idx} error: {e}"));
} else {
return Err(e);
@@ -737,18 +746,18 @@ pub fn execute_primitive(
ScriptPrimitive::Phase {
name: _name,
script,
} => execute_primitive(
script,
args,
concurrency_cap,
continue_on_error,
abort_flag,
live,
session_dir,
workspaces,
findings,
timeout_ms,
),
} => execute_primitive(PrimitiveCtx {
primitive: script,
args: pc.args,
concurrency_cap: pc.concurrency_cap,
continue_on_error: pc.continue_on_error,
abort_flag: pc.abort_flag,
live: pc.live,
session_dir: pc.session_dir,
workspaces: pc.workspaces,
findings: pc.findings,
timeout_ms: pc.timeout_ms,
}),
}
}
@@ -792,18 +801,18 @@ pub fn run_workflow_tracked(
};
let findings = Arc::new(Mutex::new(Vec::new()));
let results = execute_primitive(
&script.script,
let results = execute_primitive(PrimitiveCtx {
primitive: &script.script,
args,
concurrency_cap,
script.options.continue_on_error,
continue_on_error: script.options.continue_on_error,
abort_flag,
live,
session_dir,
workspaces,
&findings,
script.options.timeout_ms,
)?;
findings: &findings,
timeout_ms: script.options.timeout_ms,
})?;
let summary = if results.is_empty() {
"workflow completed with no output".to_string()
@@ -25,7 +25,7 @@
//! Synthesis node reads the complete collective state and converges it
//! into one unified voice — returned to LO and persisted to docs/runs/*.md.
//! ```
use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn};
use crate::app::workflow::engine::{execute_primitive, AgentStatus, LiveStateFn, PrimitiveCtx};
use crate::app::workflow::script::ScriptPrimitive;
use serde::Deserialize;
use std::collections::HashMap;
@@ -211,18 +211,18 @@ fn execute_cycle(
let args: HashMap<String, String> = HashMap::new();
let abort_owned = ctx.abort_flag.cloned();
let results = execute_primitive(
&cycle_primitive,
&args,
directives.len().clamp(1, ctx.max_cycle_concurrency),
true,
&abort_owned,
ctx.live,
ctx.session_dir,
ctx.workspaces,
ctx.collective_state,
ctx.node_timeout_ms,
)?;
let results = execute_primitive(PrimitiveCtx {
primitive: &cycle_primitive,
args: &args,
concurrency_cap: directives.len().clamp(1, ctx.max_cycle_concurrency),
continue_on_error: true,
abort_flag: &abort_owned,
live: ctx.live,
session_dir: ctx.session_dir,
workspaces: ctx.workspaces,
findings: ctx.collective_state,
timeout_ms: ctx.node_timeout_ms,
})?;
let mut reports = Vec::new();
for (node_id, output) in node_ids.iter().zip(results.iter()) {
@@ -280,9 +280,10 @@ pub fn run_hive_mind(
}
let store_base_dir = zesdex_entities::seaorm::common::store::Store::new().base_dir;
let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let settings =
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms);
let max_cycle_concurrency = settings.workflow_max_concurrency.max(1);
@@ -404,18 +405,18 @@ fn synthesize_consensus(
let args: HashMap<String, String> = HashMap::new();
let abort_owned: Option<Arc<AtomicBool>> = abort_flag.cloned();
let results = execute_primitive(
&synthesis,
&args,
1,
false,
&abort_owned,
let results = execute_primitive(PrimitiveCtx {
primitive: &synthesis,
args: &args,
concurrency_cap: 1,
continue_on_error: false,
abort_flag: &abort_owned,
live,
session_dir,
workspaces,
collective_state,
node_timeout_ms,
)?;
findings: collective_state,
timeout_ms: node_timeout_ms,
})?;
Ok(results.into_iter().next().unwrap_or_default())
}
+1 -1
View File
@@ -16,8 +16,8 @@ pub mod chat {
pub mod provider {
pub mod request {
pub use zesdex_dto::provider::request::*;
pub use zesdex_dto::provider::request::ChatCompletionRequest as ChatRequest;
pub use zesdex_dto::provider::request::*;
}
pub mod response {
pub use zesdex_dto::provider::response::ChatCompletionResponse as ChatResponse;
+109 -70
View File
@@ -1,4 +1,9 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Zesdex binary entry point.
//!
//! Parses `--daemon` / `--attach <id>` flags to select one of three
@@ -6,25 +11,27 @@
//! attach-only TUI client), sets up file logging, and runs the
//! corresponding event loop.
use anyhow::Result;
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use std::io;
use std::io::Write;
use std::sync::Mutex;
use anyhow::Result;
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use zesdex_iam::domain::repository::{SessionLockRepository, SessionRepository};
use zesdex_cms::domain::repository::SettingsRepository;
use zesdex_iam::domain::repository::{SessionLockRepository, SessionRepository};
mod app;
mod controller;
mod dto;
mod ipc;
mod model;
mod resources;
mod service;
mod tool;
mod resources;
mod view;
/// RAII guard that releases a session lock on drop, restoring the
@@ -55,7 +62,8 @@ impl<L: zesdex_iam::domain::repository::SessionLockRepository> Drop for SessionL
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let is_daemon = args.iter().any(|a| a == "--daemon");
let attach_session = args.iter()
let attach_session = args
.iter()
.position(|a| a == "--attach")
.and_then(|i| args.get(i + 1).cloned());
@@ -65,11 +73,14 @@ fn main() -> Result<()> {
let _ = std::fs::create_dir_all(&log_dir);
let log_path = log_dir.join("zesdex.log");
let log_file = std::fs::OpenOptions::new()
.create(true).append(true).open(&log_path)
.create(true)
.append(true)
.open(&log_path)
.unwrap_or_else(|_| {
// Fallback: /dev/null so the TUI isn't corrupted by stderr writes
std::fs::OpenOptions::new()
.write(true).open("/dev/null")
.write(true)
.open("/dev/null")
.expect("cannot open /dev/null")
});
@@ -119,7 +130,10 @@ fn run_single_process() -> Result<()> {
if !lock_repo.try_lock(&session_dir)? {
anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)");
}
let _session_lock_guard = SessionLockGuard { lock_repo: &lock_repo, session_dir: session_dir.clone() };
let _session_lock_guard = SessionLockGuard {
lock_repo: &lock_repo,
session_dir: session_dir.clone(),
};
let workspace_roots = vec![std::env::current_dir()?];
let mut state = app::state::rest::AppStateRest::new(
@@ -128,10 +142,11 @@ fn run_single_process() -> Result<()> {
store.memory_dir,
);
state.spawn_mention_index_build();
let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
state.sessions = session_repo.list_sessions(&store.base_dir).unwrap_or_default();
let session_repo =
zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
state.sessions = session_repo
.list_sessions(&store.base_dir)
.unwrap_or_default();
let _rt = tokio::runtime::Runtime::new()?;
@@ -223,25 +238,34 @@ fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::Ke
///
/// Why: the client never shares memory with the daemon, so every action
/// on the daemon side is followed by a full state push rather than a diff.
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> {
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload};
fn send_daemon_update(
conn: &mut ipc::conn::Connection,
state: &app::state::rest::AppStateRest,
) -> Result<()> {
use ipc::protocol::{DaemonFrame, MessageEntry, StatePayload, ToastEntry};
let messages: Vec<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
MessageEntry {
let messages: Vec<MessageEntry> = state
.transcript_cache
.messages
.iter()
.map(|m| MessageEntry {
role: format!("{:?}", m.role),
content: m.content.clone(),
timestamp: m.timestamp,
}
}).collect();
})
.collect();
let toasts: Vec<ToastEntry> = state.misc.toasts.iter().map(|t| {
ToastEntry {
let toasts: Vec<ToastEntry> = state
.misc
.toasts
.iter()
.map(|t| ToastEntry {
kind: format!("{:?}", t.kind),
message: t.message.clone(),
created_at: t.created_at,
lifetime_ms: t.lifetime_ms,
}
}).collect();
})
.collect();
let overlay = if state.misc.overlay.is_active() {
Some(format!("{:?}", state.misc.overlay))
@@ -283,8 +307,10 @@ fn apply_client_update(
state.session_id = payload.session_id;
state.dirty = payload.dirty;
state.transcript_cache.messages = payload.messages.into_iter().map(|m| {
app::state::rest::ChatMessageDisplay {
state.transcript_cache.messages = payload
.messages
.into_iter()
.map(|m| app::state::rest::ChatMessageDisplay {
role: match m.role.as_str() {
"Assistant" => crate::dto::chat::message::Role::Assistant,
"System" => crate::dto::chat::message::Role::System,
@@ -293,8 +319,8 @@ fn apply_client_update(
},
content: m.content,
timestamp: m.timestamp,
}
}).collect();
})
.collect();
state.transcript_cache.dirty = true;
state.misc.overlay = match payload.overlay.as_deref() {
@@ -304,7 +330,6 @@ fn apply_client_update(
Some("Bash") => Overlay::Bash,
Some("QuitConfirm") => Overlay::QuitConfirm,
Some("KeyInput") => Overlay::KeyInput,
Some("Editor") => Overlay::Editor,
Some("Effort") => Overlay::Effort,
@@ -320,8 +345,10 @@ fn apply_client_update(
_ => Overlay::None,
};
state.misc.toasts = payload.toasts.into_iter().map(|t| {
Toast {
state.misc.toasts = payload
.toasts
.into_iter()
.map(|t| Toast {
kind: match t.kind.as_str() {
"Success" => ToastKind::Success,
"Warning" => ToastKind::Warning,
@@ -332,8 +359,8 @@ fn apply_client_update(
message: t.message,
created_at: t.created_at,
lifetime_ms: t.lifetime_ms,
}
}).collect();
})
.collect();
state.input.buffer = payload.input_buffer;
state.input.cursor = payload.input_cursor;
@@ -356,7 +383,7 @@ fn handle_daemon_client(
mut conn: ipc::conn::Connection,
state: &mut app::state::rest::AppStateRest,
) -> Result<()> {
use app::runtime::actions::{Action, apply_action};
use app::runtime::actions::{apply_action, Action};
use ipc::protocol::ClientRequest;
let mut running = true;
@@ -367,15 +394,24 @@ fn handle_daemon_client(
ClientRequest::Tick => {
apply_action(state, Action::Tick);
}
ClientRequest::KeyPress { key, ctrl, alt, shift } => {
ClientRequest::KeyPress {
key,
ctrl,
alt,
shift,
} => {
let mut modifiers = crossterm::event::KeyModifiers::NONE;
if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; }
if alt { modifiers |= crossterm::event::KeyModifiers::ALT; }
if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; }
let key_event = crossterm::event::KeyEvent::new(
key_action_to_code(&key),
modifiers,
);
if ctrl {
modifiers |= crossterm::event::KeyModifiers::CONTROL;
}
if alt {
modifiers |= crossterm::event::KeyModifiers::ALT;
}
if shift {
modifiers |= crossterm::event::KeyModifiers::SHIFT;
}
let key_event =
crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers);
let actions = controller::input::handle_key(key_event, state);
for action in actions {
apply_action(state, action);
@@ -455,7 +491,10 @@ fn run_daemon() -> Result<()> {
if !lock_repo.try_lock(&session_dir)? {
anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)");
}
let _session_lock_guard = SessionLockGuard { lock_repo: &lock_repo, session_dir: session_dir.clone() };
let _session_lock_guard = SessionLockGuard {
lock_repo: &lock_repo,
session_dir: session_dir.clone(),
};
let workspace_roots = vec![std::env::current_dir()?];
let mut state = app::state::rest::AppStateRest::new(
@@ -464,8 +503,11 @@ fn run_daemon() -> Result<()> {
store.memory_dir,
);
state.spawn_mention_index_build();
let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
state.sessions = session_repo.list_sessions(&store.base_dir).unwrap_or_default();
let session_repo =
zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new();
state.sessions = session_repo
.list_sessions(&store.base_dir)
.unwrap_or_default();
let _rt = tokio::runtime::Runtime::new()?;
@@ -492,8 +534,9 @@ fn run_daemon() -> Result<()> {
}
eprintln!("daemon: client disconnected, waiting for next connection...");
let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.save(&state.store_base_dir(), &state.settings);
let _ =
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
.save(&state.store_base_dir(), &state.settings);
}
let _ = std::fs::remove_file(&socket_path);
@@ -514,7 +557,10 @@ fn setup_attach_client(
app::state::rest::AppStateRest,
)> {
let store = model::store::Store::new();
let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock"));
let socket_path = store
.base_dir
.join("run")
.join(format!("{session_id}.sock"));
let addr = socket_path.to_string_lossy().to_string();
let client = ipc::client::IpcClient::connect_unix(&addr)?;
@@ -530,11 +576,8 @@ fn setup_attach_client(
let workspace_roots = vec![std::env::current_dir()?];
let session_dir = store.base_dir.join("sessions").join(session_id);
std::fs::create_dir_all(&session_dir)?;
let mut client_state = app::state::rest::AppStateRest::new(
workspace_roots,
&session_dir,
store.memory_dir,
);
let mut client_state =
app::state::rest::AppStateRest::new(workspace_roots, &session_dir, store.memory_dir);
client_state.session_id = session_id.to_string();
Ok((client, terminal, client_state))
@@ -551,21 +594,17 @@ fn handle_daemon_frame(
}
Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {}
Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => {
client_state.push_toast(
app::state::types::Toast::new(
app::state::types::ToastKind::Info,
message,
),
);
client_state.push_toast(app::state::types::Toast::new(
app::state::types::ToastKind::Info,
message,
));
}
Some(ipc::protocol::DaemonFrame::ClipboardCopy(text)) => {
let _ = write_osc52(&mut io::stdout(), &text);
client_state.push_toast(
app::state::types::Toast::new(
app::state::types::ToastKind::Success,
"Copied to clipboard".to_string(),
),
);
client_state.push_toast(app::state::types::Toast::new(
app::state::types::ToastKind::Success,
"Copied to clipboard".to_string(),
));
}
Some(ipc::protocol::DaemonFrame::Closed) | None => {
client_state.quit = true;
@@ -719,10 +758,10 @@ fn run_loop_inner(
state: &mut app::state::rest::AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
use std::time::Duration;
use crossterm::event::{Event, KeyEventKind, MouseEventKind};
use app::runtime::actions::{apply_action, Action};
use controller::input::handle_key;
use app::runtime::actions::{Action, apply_action};
use crossterm::event::{Event, KeyEventKind, MouseEventKind};
use std::time::Duration;
loop {
if state.quit {
@@ -14,13 +14,11 @@ use crate::app::subagent::spawn::AgentDefinition;
/// researcher, planner).
pub fn builtin_agents() -> Vec<AgentDefinition> {
vec![
AgentDefinition::new(
"coder".to_string(),
"coder".to_string(),
).with_system_prompt(
"You are a coding agent. Write correct, idiomatic Rust code.".to_string()
).with_allowed_tools(
vec![
AgentDefinition::new("coder".to_string(), "coder".to_string())
.with_system_prompt(
"You are a coding agent. Write correct, idiomatic Rust code.".to_string(),
)
.with_allowed_tools(vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
@@ -35,16 +33,14 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
"lsp_references".to_string(),
"lsp_completion".to_string(),
"lsp_disconnect".to_string(),
]
).with_max_steps(usize::MAX),
AgentDefinition::new(
"reviewer".to_string(),
"reviewer".to_string(),
).with_system_prompt(
"You are a code reviewer. Focus on correctness, safety, and performance.".to_string()
).with_allowed_tools(
vec![
])
.with_max_steps(usize::MAX),
AgentDefinition::new("reviewer".to_string(), "reviewer".to_string())
.with_system_prompt(
"You are a code reviewer. Focus on correctness, safety, and performance."
.to_string(),
)
.with_allowed_tools(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
@@ -54,39 +50,34 @@ pub fn builtin_agents() -> Vec<AgentDefinition> {
"lsp_hover".to_string(),
"lsp_definition".to_string(),
"lsp_references".to_string(),
]
).with_max_steps(usize::MAX),
AgentDefinition::new(
"researcher".to_string(),
"researcher".to_string(),
).with_system_prompt(
"You are a research agent. Search for information and summarize findings.".to_string()
).with_allowed_tools(
vec![
])
.with_max_steps(usize::MAX),
AgentDefinition::new("researcher".to_string(), "researcher".to_string())
.with_system_prompt(
"You are a research agent. Search for information and summarize findings."
.to_string(),
)
.with_allowed_tools(vec![
"read".to_string(),
"grep".to_string(),
"glob".to_string(),
"bash".to_string(),
"search_web".to_string(),
"fetch_url".to_string(),
]
).with_max_steps(usize::MAX),
AgentDefinition::new(
"planner".to_string(),
"planner".to_string(),
).with_system_prompt(
"You are a planning agent. Break down tasks into clear steps.".to_string()
).with_allowed_tools(
vec![
])
.with_max_steps(usize::MAX),
AgentDefinition::new("planner".to_string(), "planner".to_string())
.with_system_prompt(
"You are a planning agent. Break down tasks into clear steps.".to_string(),
)
.with_allowed_tools(vec![
"read".to_string(),
"write".to_string(),
"edit".to_string(),
"bash".to_string(),
"todo_write".to_string(),
"todo_finish".to_string(),
]
).with_max_steps(usize::MAX),
])
.with_max_steps(usize::MAX),
]
}
@@ -1,8 +1,8 @@
#![allow(dead_code)]
//! Load, save, add, and remove agent definitions scoped to a single
//! session (`<session_dir>/agents.json`).
use std::path::Path;
use crate::app::subagent::spawn::AgentDefinition;
use std::path::Path;
/// Load agent definitions saved for a specific session.
///
@@ -21,12 +21,10 @@ pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
return Vec::new();
}
match std::fs::read_to_string(&agents_file) {
Ok(content) => {
serde_json::from_str(&content).unwrap_or_else(|e| {
tracing::warn!("[session] failed to parse agents.json: {}", e);
Vec::new()
})
}
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
tracing::warn!("[session] failed to parse agents.json: {}", e);
Vec::new()
}),
Err(_) => Vec::new(),
}
}
+1 -1
View File
@@ -5,6 +5,6 @@
pub mod store {
pub use zesdex_entities::seaorm::common::store::*;
}
pub mod agent_def;
/// Local modules not extracted to workspace crates
pub mod msglog;
pub mod agent_def;
@@ -107,6 +107,7 @@ impl LlmClient {
stop: None,
stream_options: None,
tool_choice: None,
top_p: None,
};
let url = format!("{}/chat/completions", self.base_url);
@@ -143,12 +144,9 @@ impl LlmClient {
}
let data: crate::dto::provider::response::ChatResponse = resp.json()?;
let usage = data.usage.map(|u| {
(
u64::from(u.prompt_tokens),
u64::from(u.completion_tokens),
)
});
let usage = data
.usage
.map(|u| (u64::from(u.prompt_tokens), u64::from(u.completion_tokens)));
let message = data
.choices
.into_iter()
@@ -204,6 +202,7 @@ impl LlmClient {
include_usage: true,
}),
tool_choice: None,
top_p: None,
};
let url = format!("{}/chat/completions", self.base_url);
+3 -11
View File
@@ -2,7 +2,7 @@
//! and `bash_kill`. Both take a `job_id` produced by `bash` with `run_in_background=true`.
use super::Tool;
use super::ToolCtx;
use anyhow::{anyhow, Result};
use anyhow::Result;
use serde_json::{json, Value};
/// Tool: fetch buffered output from a background bash job by `job_id`.
@@ -31,11 +31,7 @@ impl Tool for BashOutput {
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let job_id = args
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
.to_string();
let job_id = crate::tool::arg_str(args, "job_id")?;
// Validate that job_id looks like a UUID to prevent injection
// into the global job registry.
if !is_valid_job_id(&job_id) {
@@ -74,11 +70,7 @@ impl Tool for BashKill {
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let job_id = args
.get("job_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: job_id"))?
.to_string();
let job_id = crate::tool::arg_str(args, "job_id")?;
if !is_valid_job_id(&job_id) {
anyhow::bail!("invalid job_id format: expected UUID");
}
+1 -1
View File
@@ -2,7 +2,7 @@
use super::super::resolve_path;
use super::super::Tool;
use super::super::ToolCtx;
use super::helpers::arg_str;
use crate::tool::arg_str;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::fs;
+2 -1
View File
@@ -9,7 +9,8 @@ use super::super::check_graduated_checks;
use super::super::resolve_path;
use super::super::Tool;
use super::super::ToolCtx;
use super::helpers::{self, arg_str};
use super::helpers;
use crate::tool::arg_str;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use similar::TextDiff;
+1 -40
View File
@@ -1,19 +1,8 @@
//! Shared helpers for filesystem tools: extracting string arguments from JSON
//! and producing user-friendly "not found" diagnostics.
use anyhow::{anyhow, Result};
use serde_json::Value;
use std::path::Path;
/// Extract a required string argument from a JSON args map.
///
/// Return: the value as `String` if present and a string type; `Err` if missing
/// or of a different JSON type (null, number, boolean, array, object).
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!("missing required argument: {name}"))
}
/// Produce a user-friendly diagnostic string when a path doesn't resolve or exist.
///
@@ -70,35 +59,7 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_arg_str_found() {
let args = json!({"key": "value"});
assert_eq!(arg_str(&args, "key").unwrap(), "value");
}
#[test]
fn test_arg_str_missing() {
let args = json!({"other": "value"});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_arg_str_empty_string() {
let args = json!({"key": ""});
assert_eq!(arg_str(&args, "key").unwrap(), "");
}
#[test]
fn test_arg_str_wrong_type() {
let args = json!({"key": 42});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_arg_str_null() {
let args = json!({"key": null});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_truncate_diff_under_limit_unchanged() {
+2 -1
View File
@@ -8,7 +8,8 @@
use super::super::resolve_path;
use super::super::Tool;
use super::super::ToolCtx;
use super::helpers::{arg_str, not_found_help};
use super::helpers::not_found_help;
use crate::tool::arg_str;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::fs;
+2 -1
View File
@@ -3,7 +3,8 @@ use super::super::check_graduated_checks;
use super::super::resolve_path;
use super::super::Tool;
use super::super::ToolCtx;
use super::helpers::{self, arg_str};
use super::helpers;
use crate::tool::arg_str;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use similar::TextDiff;
+6 -17
View File
@@ -40,22 +40,11 @@ impl Tool for GitCred {
///
/// Return: combined stdout+stderr on success; error with stderr on non-zero exit.
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let operation = args
.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: operation"))?;
let output = Command::new("git")
.arg("credential")
.arg(operation)
.output()
.map_err(|e| anyhow!("git credential failed: {e}"))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok(format!("{stdout}{stderr}"))
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
anyhow::bail!("git credential '{}' failed: {}", operation, stderr.trim())
}
let operation = crate::tool::arg_str(args, "operation")?;
let mut cmd = Command::new("git");
cmd.arg("credential").arg(&operation);
crate::tool::execute_cmd(&mut cmd)
.map_err(|e| anyhow!("git credential '{}' failed: {}", operation, e))
}
}
+7 -27
View File
@@ -53,11 +53,7 @@ impl Tool for GitOperator {
/// Return: trimmed combined output on success; error including exit code and
/// stderr on failure.
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let operation = args
.get("operation")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: operation"))?
.to_string();
let operation = crate::tool::arg_str(args, "operation")?;
let arg_list: Vec<String> = args
.get("args")
.and_then(|v| v.as_array())
@@ -73,27 +69,11 @@ impl Tool for GitOperator {
let cmd_for_filter = format!("git {} {}", operation, arg_list.join(" "));
crate::tool::shell_filter::git::check_git_destructive(&cmd_for_filter)
.map_err(|e| anyhow!("blocked: {e}"))?;
let output = Command::new("git")
.arg(&operation)
.args(&arg_list)
.output()
.map_err(|e| anyhow!("git {operation} failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() {
stdout.trim().to_string()
} else {
format!("{}\n{}", stdout.trim(), stderr.trim())
};
if output.status.success() {
Ok(combined)
} else {
anyhow::bail!(
"git {} failed (exit {}): {}",
operation,
output.status.code().unwrap_or(-1),
stderr.trim()
)
}
let mut cmd = Command::new("git");
cmd.arg(&operation)
.args(&arg_list);
crate::tool::execute_cmd(&mut cmd)
.map_err(|e| anyhow!("git {operation} failed: {e}"))
}
}
+11 -32
View File
@@ -42,45 +42,24 @@ impl Tool for GitWorktree {
/// Return: success message with combined output on success; error including exit
/// code and stderr on failure.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: name"))?
.to_string();
let name = crate::tool::arg_str(args, "name")?;
if name.contains('/') || name.contains('\\') || name.contains("..") {
anyhow::bail!("worktree name must not contain path separators or '..'");
}
let base_ref = args
.get("base_ref")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: base_ref"))?
.to_string();
let base_ref = crate::tool::arg_str(args, "base_ref")?;
let worktree_path = ctx.worktrees_dir.join(&name);
std::fs::create_dir_all(&worktree_path)
.map_err(|e| anyhow!("failed to create worktree directory: {e}"))?;
let output = Command::new("git")
.args(["worktree", "add", "--checkout"])
let mut cmd = Command::new("git");
cmd.args(["worktree", "add", "--checkout"])
.arg(worktree_path.display().to_string())
.arg(&base_ref)
.output()
.arg(&base_ref);
let output = crate::tool::execute_cmd(&mut cmd)
.map_err(|e| anyhow!("git worktree add failed: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() {
stdout.trim().to_string()
} else {
format!("{}\n{}", stdout.trim(), stderr.trim())
};
if output.status.success() {
Ok(format!(
"created worktree '{name}' from '{base_ref}'\n{combined}"
))
} else {
anyhow::bail!(
"git worktree add failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr.trim()
)
}
Ok(format!(
"created worktree '{name}' from '{base_ref}'\n{output}"
))
}
}
+78 -198
View File
@@ -51,18 +51,9 @@ impl Tool for LspConnect {
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: name"))?;
let command = args
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: command"))?;
let language_id = args
.get("language_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: language_id"))?;
let name = crate::tool::arg_str(args, "name")?;
let command = crate::tool::arg_str(args, "command")?;
let language_id = crate::tool::arg_str(args, "language_id")?;
let extra_args: Vec<String> = args
.get("args")
.and_then(|v| v.as_array())
@@ -77,17 +68,17 @@ impl Tool for LspConnect {
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
manager.connect(command, &extra_args, language_id)?;
manager.connect(&command, &extra_args, &language_id)?;
// Auto-register this server's known extensions so lsp_diagnostics /
// lsp_hover / lsp_completion / lsp_definition / lsp_references can
// auto-detect it later without an explicit `server` argument.
let known_exts = known_extensions_for(language_id);
let known_exts = known_extensions_for(&language_id);
if !known_exts.is_empty() {
manager.register_extensions(language_id, known_exts);
manager.register_extensions(&language_id, known_exts);
}
let client_arc = manager.get_client(language_id);
let client_arc = manager.get_client(&language_id);
let caps = client_arc
.and_then(|c| {
c.lock()
@@ -138,18 +129,12 @@ impl Tool for LspDiagnostics {
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let text = args
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: text"))?;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let rel_path = crate::tool::arg_str(args, "path")?;
let text = crate::tool::arg_str(args, "text")?;
let server_name = resolve_server_name(ctx, args, &rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let manager = ctx
@@ -168,7 +153,7 @@ impl Tool for LspDiagnostics {
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
match client.collect_diagnostics(&uri, &language_id, text) {
match client.collect_diagnostics(&uri, &language_id, &text) {
Ok(diags) => {
let diags_array = diags.as_array().cloned().unwrap_or_default();
if diags_array.is_empty() {
@@ -279,52 +264,12 @@ impl Tool for LspHover {
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager.get_language_id(server_name).unwrap_or_else(|| {
args.get("language_id")
.and_then(|v| v.as_str())
.unwrap_or("plaintext")
.to_string()
let result = run_lsp_query(ctx, args, |client, uri, line, column| {
client.hover(uri, line, column)
});
let client_arc = manager.get_client(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.hover(&uri, line, column);
let _ = client.did_close(&uri);
match result {
Ok(hover_result) => {
Ok((hover_result, _line, _column)) => {
if hover_result == Value::Null {
return Ok("No hover information available at this position.".to_string());
}
@@ -424,49 +369,12 @@ impl Tool for LspCompletion {
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager
.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.completion(&uri, line, column);
let _ = client.did_close(&uri);
let result = run_lsp_query(ctx, args, |client, uri, line, column| {
client.completion(uri, line, column)
});
match result {
Ok(completion_result) => {
Ok((completion_result, line, column)) => {
let items = if let Some(items) = completion_result.as_array() {
items.clone()
} else if let Some(arr) = completion_result.get("items").and_then(|v| v.as_array())
@@ -576,49 +484,12 @@ impl Tool for LspDefinition {
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager
.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.goto_definition(&uri, line, column);
let _ = client.did_close(&uri);
let result = run_lsp_query(ctx, args, |client, uri, line, column| {
client.goto_definition(uri, line, column)
});
match result {
Ok(def_result) => {
Ok((def_result, _line, _column)) => {
if def_result == Value::Null {
return Ok("No definition found at this position.".to_string());
}
@@ -696,49 +567,12 @@ impl Tool for LspReferences {
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel_path = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, rel_path)?;
let server_name = server_name.as_str();
let abs_path = crate::tool::resolve_path(&ctx.workspaces, rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager
.get_language_id(server_name)
.unwrap_or_else(|| "plaintext".to_string());
let client_arc = manager.get_client(server_name).ok_or_else(|| {
anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = client.references(&uri, line, column);
let _ = client.did_close(&uri);
let result = run_lsp_query(ctx, args, |client, uri, line, column| {
client.references(uri, line, column)
});
match result {
Ok(ref_result) => {
Ok((ref_result, _line, _column)) => {
let locations = ref_result.as_array().cloned().unwrap_or_default();
if locations.is_empty() {
return Ok("No references found for this symbol.".to_string());
@@ -794,17 +628,14 @@ impl Tool for LspDisconnect {
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: name"))?;
let name = crate::tool::arg_str(args, "name")?;
let mut manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow!("LSP manager lock error: {e}"))?;
if manager.disconnect(name) {
if manager.disconnect(&name) {
Ok(format!("Disconnected from LSP server '{name}'"))
} else {
Err(anyhow!("LSP server '{name}' not found"))
@@ -842,6 +673,55 @@ fn known_extensions_for(language_id: &str) -> &[&'static str] {
/// a server connected without an explicit `register_extensions` call. Returns
/// `None` if the path has no extension, the lock is poisoned, or no
/// connected server's language is known to use that extension.
fn run_lsp_query<F, R>(ctx: &ToolCtx, args: &Value, op: F) -> Result<(R, u32, u32)>
where
F: FnOnce(&mut crate::app::lsp::LspClient, &str, u32, u32) -> Result<R>,
{
let rel_path = crate::tool::arg_str(args, "path")?;
let line = args
.get("line")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow::anyhow!("missing required argument: line"))? as u32;
let column =
args.get("column")
.and_then(serde_json::Value::as_i64)
.ok_or_else(|| anyhow::anyhow!("missing required argument: column"))? as u32;
let server_name = resolve_server_name(ctx, args, &rel_path)?;
let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?;
let uri = path_to_lsp_uri(&abs_path.to_string_lossy());
let file_content = std::fs::read_to_string(&abs_path)
.map_err(|e| anyhow::anyhow!("failed to read file '{rel_path}': {e}"))?;
let manager = ctx
.lsp_manager
.lock()
.map_err(|e| anyhow::anyhow!("LSP manager lock error: {e}"))?;
let language_id = manager
.get_language_id(&server_name)
.unwrap_or_else(|| {
args.get("language_id")
.and_then(|v| v.as_str())
.unwrap_or("plaintext")
.to_string()
});
let client_arc = manager.get_client(&server_name).ok_or_else(|| {
anyhow::anyhow!("LSP server '{server_name}' not found. Use lsp_connect first.")
})?;
drop(manager);
let mut client = client_arc
.lock()
.map_err(|e| anyhow::anyhow!("LSP client lock error: {e}"))?;
client.did_open(&uri, &language_id, 1, &file_content)?;
let result = op(&mut client, &uri, line, column);
let _ = client.did_close(&uri);
result.map(|r| (r, line, column))
}
fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option<String> {
let ext = std::path::Path::new(path)
.extension()
@@ -1,10 +1,10 @@
//! Tool for deleting a persisted memory entry by name.
use super::super::Tool;
use super::super::ToolCtx;
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
/// Tool that removes a single memory entry from `ctx.memory_dir` by exact name.
pub struct Forget;
@@ -38,12 +38,10 @@ impl Tool for Forget {
/// Return: confirmation message on success; error if the memory does not exist
/// or the file could not be removed.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: name"))?;
let name = crate::tool::arg_str(args, "name")?;
MarkdownMemoryRepository::new().delete(&ctx.memory_dir, name)
MarkdownMemoryRepository::new()
.delete(&ctx.memory_dir, &name)
.map_err(|e| anyhow!("failed to remove memory '{name}': {e}"))?;
Ok(format!("removed memory '{name}'"))
@@ -1,11 +1,11 @@
//! Tool for reading a single memory entry or listing the whole memory index.
use super::super::Tool;
use super::super::ToolCtx;
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::fmt::Write;
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
/// Tool that reads one memory entry by name, or lists all entries when name is omitted.
pub struct Recall;
@@ -42,7 +42,8 @@ impl Tool for Recall {
if name.is_empty() {
return Ok(list_all(ctx));
}
let memory = MarkdownMemoryRepository::new().load(&ctx.memory_dir, name)
let memory = MarkdownMemoryRepository::new()
.load(&ctx.memory_dir, name)
.map_err(|e| anyhow!("memory '{name}' not found: {e}"))?;
Ok(format!(
"---\nname: {}\ndescription: {}\nkind: {}\nlifecycle: {}\n---\n\n{}",
@@ -61,7 +62,9 @@ impl Tool for Recall {
///
/// Return: `Ok` with the formatted index (never fails; missing dir yields "(no memory entries)").
fn list_all(ctx: &ToolCtx) -> String {
let names = MarkdownMemoryRepository::new().list(&ctx.memory_dir).unwrap_or_default();
let names = MarkdownMemoryRepository::new()
.list(&ctx.memory_dir)
.unwrap_or_default();
if names.is_empty() {
return "(no memory entries)".to_string();
}
@@ -1,11 +1,11 @@
//! Tool for saving a new memory entry to persistent project memory.
use super::super::Tool;
use super::super::ToolCtx;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use zesdex_cms::domain::memory::Memory;
use zesdex_cms::domain::repository::MemoryRepository;
use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository;
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
/// Tool that writes a new `Memory` entry (name/description/content/kind) to disk.
pub struct Remember;
@@ -56,24 +56,12 @@ impl Tool for Remember {
///
/// Return: confirmation string on success; error if name is invalid or the write fails.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: name"))?;
let description = args
.get("description")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: description"))?;
let content = args
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: content"))?;
let kind = args
.get("kind")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: kind"))?;
let name = crate::tool::arg_str(args, "name")?;
let description = crate::tool::arg_str(args, "description")?;
let content = crate::tool::arg_str(args, "content")?;
let kind = crate::tool::arg_str(args, "kind")?;
if Memory::slugify(name).is_none() {
if Memory::slugify(&name).is_none() {
anyhow::bail!("invalid memory name: must produce a valid slug (alphanumeric + hyphens, 1-80 chars)");
}
@@ -93,7 +81,8 @@ impl Tool for Remember {
provenances: vec![],
};
MarkdownMemoryRepository::new().save(&ctx.memory_dir, &memory)
MarkdownMemoryRepository::new()
.save(&ctx.memory_dir, &memory)
.map_err(|e| anyhow!("failed to save memory '{name}': {e}"))?;
Ok(format!("saved memory '{name}' ({kind})"))
+62
View File
@@ -237,6 +237,37 @@ pub fn tool_defs(tools: &[Box<dyn Tool>]) -> Vec<crate::dto::provider::request::
.collect()
}
/// Extract a required string argument from a JSON args map.
///
/// Return: the value as `String` if present and a string type; `Err` if missing
/// or of a different JSON type (null, number, boolean, array, object).
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.
///
/// Return: `Ok(output)` on success, `Err(combined)` on non-zero exit or failure.
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 combined = if stderr.is_empty() {
stdout
} else {
format!("{}\n{}", stdout, stderr).trim().to_string()
};
if output.status.success() {
Ok(combined)
} else {
let code = output.status.code().unwrap_or(-1);
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.
///
@@ -305,10 +336,41 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result<PathBuf> {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tool_ctx_builder_defaults_abort_flag_to_none() {
let ctx = ToolCtx::builder().build();
assert!(ctx.abort_flag.is_none());
}
#[test]
fn test_arg_str_found() {
let args = json!({"key": "value"});
assert_eq!(arg_str(&args, "key").unwrap(), "value");
}
#[test]
fn test_arg_str_missing() {
let args = json!({"other": "value"});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_arg_str_empty_string() {
let args = json!({"key": ""});
assert_eq!(arg_str(&args, "key").unwrap(), "");
}
#[test]
fn test_arg_str_wrong_type() {
let args = json!({"key": 42});
assert!(arg_str(&args, "key").is_err());
}
#[test]
fn test_arg_str_null() {
let args = json!({"key": null});
assert!(arg_str(&args, "key").is_err());
}
}
+4 -13
View File
@@ -1,7 +1,7 @@
//! Plan-mode signaling tools: entering plan mode with a proposal, and confirming readiness.
use super::Tool;
use super::ToolCtx;
use anyhow::{anyhow, Result};
use anyhow::Result;
use serde_json::{json, Value};
/// Tool the model calls to present a step-by-step plan and enter plan mode.
@@ -38,14 +38,8 @@ impl Tool for PlanEnter {
///
/// Return: fixed acknowledgement string on success; error if either arg is missing.
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let _ = args
.get("plan")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: plan"))?;
let _ = args
.get("sign_off")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: sign_off"))?;
let _ = crate::tool::arg_str(args, "plan")?;
let _ = crate::tool::arg_str(args, "sign_off")?;
Ok("plan recorded".to_string())
}
}
@@ -79,10 +73,7 @@ impl Tool for PlanReady {
///
/// Return: fixed "ready to execute" string on success; error if `confirmation` is missing.
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let _ = args
.get("confirmation")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: confirmation"))?;
let _ = crate::tool::arg_str(args, "confirmation")?;
Ok("ready to execute".to_string())
}
}
+4 -20
View File
@@ -48,16 +48,8 @@ impl Tool for Grep {
///
/// Return: "no matches found" if empty, else a header + `path:line:text` rows.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let pattern = args
.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: pattern"))?
.to_string();
let rel = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?
.to_string();
let pattern = crate::tool::arg_str(args, "pattern")?;
let rel = crate::tool::arg_str(args, "path")?;
let path = resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
anyhow::bail!("path '{rel}' does not exist");
@@ -137,16 +129,8 @@ impl Tool for Glob {
///
/// Return: sorted newline-joined matches; "no files match" sentinel if empty.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let pat_str = args
.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: pattern"))?
.to_string();
let rel = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?
.to_string();
let pat_str = crate::tool::arg_str(args, "pattern")?;
let rel = crate::tool::arg_str(args, "path")?;
let root = resolve_path(&ctx.workspaces, &rel)?;
if !root.exists() || !root.is_dir() {
anyhow::bail!("path '{rel}' is not a valid directory");
+1 -5
View File
@@ -64,11 +64,7 @@ impl Tool for Bash {
/// Return: exit-code + elapsed-seconds summary line (plus captured output) for
/// foreground runs, or the job ID for background runs.
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let cmd = args
.get("command")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: command"))?
.to_string();
let cmd = crate::tool::arg_str(args, "command")?;
let timeout_ms = args
.get("timeout")
.and_then(serde_json::Value::as_u64)
@@ -5,7 +5,6 @@
//! module is kept for callers that DO want to block credential reads (e.g.
//! a future sandboxed/untrusted-tool execution path) and is covered by its
//! own inline tests below.
use anyhow::Result;
/// Reject shell commands whose lowercased form contains any known credential-read pattern.
///
@@ -19,59 +18,6 @@ use anyhow::Result;
/// model could insert quotes between characters to bypass substring matching.
///
/// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise.
pub fn check_credential_read(cmd: &str) -> Result<()> {
let patterns = [
// SSH key files
"cat ~/.ssh",
"cat /home/",
".ssh/id_rsa",
".ssh/id_ed25519",
".ssh/id_ecdsa",
".ssh/id_dsa",
".ssh/authorized_keys",
".ssh/known_hosts",
// Git / generic credential files
".git-credentials",
".netrc",
// Cloud credentials
"aws/credentials",
"gcloud/credentials",
".config/gcloud",
".config/gh",
// Container/K8s credentials
".docker/config.json",
".kube/config",
".npmrc",
// Token/key patterns in command strings
"token=",
"secret=",
"api_key=",
"api-key=",
"password=",
"ghp_",
"ghs_",
"sk-",
"akia",
"bearer ",
// Environment variable dumpers
" env",
"printenv",
"/proc/self/environ",
];
let cmd_lower = cmd.to_lowercase();
let cmd_no_quotes: String = cmd_lower.chars()
.filter(|&c| c != '\'' && c != '"')
.collect();
// Also check against ANSI-C quoting normalization so that
// $'cat\u0020~/.ssh/id_rsa' does not bypass the filter.
let cmd_normalized = super::normalize_ansi_c_quoting(&cmd_no_quotes);
for pattern in &patterns {
if cmd_lower.contains(pattern) || cmd_no_quotes.contains(pattern) || cmd_normalized.contains(pattern) {
anyhow::bail!("credential read blocked: '{}'", pattern);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
@@ -44,10 +44,7 @@ pub fn check_git_destructive(cmd: &str) -> Result<()> {
"push --tags --force",
];
let cmd_lower = cmd.to_lowercase();
let cmd_no_quotes: String = cmd_lower
.chars()
.filter(|&c| c != '\'' && c != '"')
.collect();
let cmd_no_quotes = super::strip_quotes(&cmd_lower);
// Normalize ANSI-C quoting ($'...') which can encode spaces and
// special characters as escape sequences (e.g. $'push\u0020--force'
// → "push --force"), bypassing the raw substring matching above.
@@ -1,5 +1,11 @@
//! Pre-execution safety filters applied to shell commands before they're spawned.
pub mod git;
pub mod credentials;
/// Strip single and double quotes from a string.
pub(crate) fn strip_quotes(s: &str) -> String {
s.chars().filter(|&c| c != '\'' && c != '"').collect()
}
/// Decode ANSI-C quoted strings ($'...') found in `input`, replacing
/// them with their unquoted, escape-decoded equivalents.
+25 -24
View File
@@ -9,6 +9,7 @@
//! Also provides a pipeline variant: `spawn_pipeline` runs agents
//! sequentially so each stage sees the previous stage's findings.
use super::{Tool, ToolCtx};
use crate::app::workflow::engine::PrimitiveCtx;
use crate::app::workflow::script::{ScriptOptions, ScriptPrimitive, WorkflowScript};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
@@ -114,18 +115,18 @@ impl Tool for SpawnAgents {
// spawn_agents or workflow_run invocations.
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
let results = crate::app::workflow::engine::execute_primitive(
&wf.script,
&HashMap::new(),
max_concurrency,
true,
&no_abort,
live.as_ref(),
&ctx.session_dir,
&ctx.workspaces,
&findings,
None, // no per-agent timeout for spawn_agents
)?;
let results = crate::app::workflow::engine::execute_primitive(PrimitiveCtx {
primitive: &wf.script,
args: &HashMap::new(),
concurrency_cap: max_concurrency,
continue_on_error: true,
abort_flag: &no_abort,
live: live.as_ref(),
session_dir: &ctx.session_dir,
workspaces: &ctx.workspaces,
findings: &findings,
timeout_ms: None,
})?;
Ok(format_results(&results, "parallel"))
}
}
@@ -210,18 +211,18 @@ impl Tool for SpawnPipeline {
// other concurrent spawn_agents / spawn_pipeline / workflow_run.
let findings: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let no_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>> = None;
let results = crate::app::workflow::engine::execute_primitive(
&wf.script,
&HashMap::new(),
1,
false,
&no_abort,
live.as_ref(),
&ctx.session_dir,
&ctx.workspaces,
&findings,
None, // no per-agent timeout for spawn_pipeline
)?;
let results = crate::app::workflow::engine::execute_primitive(PrimitiveCtx {
primitive: &wf.script,
args: &HashMap::new(),
concurrency_cap: 1,
continue_on_error: false,
abort_flag: &no_abort,
live: live.as_ref(),
session_dir: &ctx.session_dir,
workspaces: &ctx.workspaces,
findings: &findings,
timeout_ms: None,
})?;
Ok(format_results(&results, "pipeline"))
}
}
+3 -6
View File
@@ -1,7 +1,7 @@
//! `cd` tool: verify and resolve a workspace-relative directory path.
use super::super::Tool;
use super::super::ToolCtx;
use anyhow::{anyhow, Result};
use anyhow::Result;
use serde_json::{json, Value};
/// Tool that resolves a workspace-relative path and reports whether it exists and is a dir.
@@ -40,12 +40,9 @@ impl Tool for Cd {
/// Return: canonical path on success; explicit "does not exist" / "not a directory"
/// message (still `Ok`) so the model can react without treating it as an error.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let rel = crate::tool::arg_str(args, "path")?;
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
return Ok(format!(
@@ -51,12 +51,9 @@ impl Tool for DirCacheUpdate {
/// Return: a confirmation string with the entry count, or an error if
/// the `path` argument is missing or the temp runtime fails to start.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let rel = crate::tool::arg_str(args, "path")?;
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
return Ok(format!(
@@ -52,12 +52,9 @@ impl Tool for DirList {
/// Return: header + newline-joined entry names, or an error if the
/// `path` argument is missing or `read_dir` fails outright.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let rel = args
.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: path"))?;
let rel = crate::tool::arg_str(args, "path")?;
let path = super::super::resolve_path(&ctx.workspaces, rel)?;
let path = super::super::resolve_path(&ctx.workspaces, &rel)?;
if !path.exists() {
return Ok(format!(
@@ -51,10 +51,7 @@ impl Tool for Todowrite {
/// Return: confirmation string echoing the added task, or an error
/// if the `task` argument is missing or the file can't be opened/written.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let task = args
.get("task")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: task"))?;
let task = crate::tool::arg_str(args, "task")?;
let path: PathBuf = ctx.session_dir.join("todo.md");
let now = chrono::Utc::now();
+5 -14
View File
@@ -58,13 +58,10 @@ impl Tool for WorkflowRun {
/// Return: the workflow engine's output string, or an error if the
/// script argument is missing or fails to parse as JSON.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let script_str = args
.get("script")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: script"))?;
let script_str = crate::tool::arg_str(args, "script")?;
let workflow_script: crate::app::workflow::script::WorkflowScript =
serde_json::from_str(script_str)
serde_json::from_str(&script_str)
.map_err(|e| anyhow!("failed to parse workflow script: {e}"))?;
let workflow_args: std::collections::HashMap<String, String> = args
@@ -125,10 +122,7 @@ impl Tool for NoteFinding {
/// Return: confirmation string containing up to the first 80 chars
/// of the recorded text.
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let text = args
.get("text")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: text"))?;
let text = crate::tool::arg_str(args, "text")?;
if let Some(ref findings) = ctx.workflow_findings {
if let Ok(mut f) = findings.lock() {
@@ -211,10 +205,7 @@ impl Tool for HiveMind {
}
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
let request = args
.get("request")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("missing required argument: request"))?;
let request = crate::tool::arg_str(args, "request")?;
let cycles_value = args
.get("cycles")
@@ -228,7 +219,7 @@ impl Tool for HiveMind {
// itself (guaranteed, even if synthesis fails) — do not write it
// again here.
let (consensus, _reports) = crate::app::workflow::hive_mind::run_hive_mind(
request,
&request,
&plan,
&ctx.session_dir,
&ctx.workspaces,
+112 -44
View File
@@ -15,15 +15,17 @@
//! are the exception: every line gets its `" "` prefix independently
//! and consistently, so there's no first-line-only misalignment there.
use super::theme::Theme;
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use super::theme::Theme;
/// Apply the "tool output" dim/italic style, or pass `style` through
/// unchanged, depending on `dim`.
fn apply_dim(style: Style, dim: bool) -> Style {
if dim {
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC)
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC)
} else {
style
}
@@ -59,7 +61,6 @@ fn diff_line_style(line: &str) -> Option<Style> {
///
/// Return: a flat vec of styled spans; `chat::split_spans_into_lines`
/// turns it back into `Line`s for the Paragraph widget.
#[allow(clippy::too_many_lines)]
pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut options = pulldown_cmark::Options::empty();
@@ -69,7 +70,7 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
let mut in_diff_block = false;
let mut in_heading = false;
let mut heading_level = 0;
let mut in_table_cell = false;
let mut table_rows: Vec<Vec<Vec<Span<'static>>>> = Vec::new();
let mut current_row: Vec<Vec<Span<'static>>> = Vec::new();
@@ -86,18 +87,15 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
pulldown_cmark::CodeBlockKind::Fenced(lang) if lang.as_ref() == "diff"
);
// Code block top bar
spans.push(Span::styled(
"\n",
Style::default(),
));
spans.push(Span::styled("\n", Style::default()));
spans.push(Span::styled(
" ┌─ code ",
apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
));
spans.push(Span::styled(
"\n",
Style::default(),
apply_dim(
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
dim,
),
));
spans.push(Span::styled("\n", Style::default()));
}
pulldown_cmark::Tag::Heading { level, .. } => {
in_heading = true;
@@ -125,7 +123,12 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
// After the link text ends, we'll add the URL
spans.push(Span::styled(
format!("]({dest_url})"),
apply_dim(Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC), dim),
apply_dim(
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::ITALIC),
dim,
),
));
}
pulldown_cmark::Tag::BlockQuote(_) => {
@@ -155,7 +158,10 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
// Code block bottom bar
spans.push(Span::styled(
"\n └─\n",
apply_dim(Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG), dim),
apply_dim(
Style::default().fg(Theme::TEXT_MUTED).bg(Theme::CODE_BG),
dim,
),
));
}
pulldown_cmark::TagEnd::Heading(_) => {
@@ -186,7 +192,8 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
for row in &table_rows {
for (i, cell) in row.iter().enumerate() {
if i < cols_count {
let cell_width: usize = cell.iter().map(|s| s.content.chars().count()).sum();
let cell_width: usize =
cell.iter().map(|s| s.content.chars().count()).sum();
if cell_width > col_widths[i] {
col_widths[i] = cell_width;
}
@@ -194,20 +201,31 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
}
}
let effective_width = if width > 0 { (width as usize).saturating_sub(2) } else { 0 };
let effective_width = if width > 0 {
(width as usize).saturating_sub(2)
} else {
0
};
let border_overhead = cols_count * 3 + 4;
let available_width = effective_width.saturating_sub(border_overhead);
let mut total_width: usize = col_widths.iter().sum();
if width > 0 && total_width > available_width && available_width > 0 {
while total_width > available_width {
let max_idx = col_widths.iter().enumerate().max_by_key(|&(_, &w)| w).map(|(i, _)| i).unwrap();
if col_widths[max_idx] <= 3 { break; }
let max_idx = col_widths
.iter()
.enumerate()
.max_by_key(|&(_, &w)| w)
.map(|(i, _)| i)
.unwrap();
if col_widths[max_idx] <= 3 {
break;
}
col_widths[max_idx] -= 1;
total_width -= 1;
}
}
spans.push(Span::raw("\n"));
for (r, row) in table_rows.iter().enumerate() {
let mut cell_lines = Vec::new();
@@ -216,13 +234,18 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
cell_lines.push(wrap_spans_to_lines(cell, col_widths[i]));
}
}
let max_height = cell_lines.iter().map(std::vec::Vec::len).max().unwrap_or(1);
let max_height =
cell_lines.iter().map(std::vec::Vec::len).max().unwrap_or(1);
for y in 0..max_height {
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
spans.push(Span::styled(
" | ",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
for (i, cl) in cell_lines.iter().enumerate() {
let line_spans = if y < cl.len() { &cl[y] } else { [].as_slice() };
let line_spans =
if y < cl.len() { &cl[y] } else { [].as_slice() };
let mut line_width = 0;
for span in line_spans {
line_width += span.content.chars().count();
@@ -230,15 +253,24 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
}
let pad = col_widths[i].saturating_sub(line_width);
spans.push(Span::raw(" ".repeat(pad)));
spans.push(Span::styled(" | ", apply_dim(Style::default().fg(Theme::BORDER), dim)));
spans.push(Span::styled(
" | ",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
}
spans.push(Span::raw("\n"));
}
if r == 0 {
spans.push(Span::styled(" |", apply_dim(Style::default().fg(Theme::BORDER), dim)));
spans.push(Span::styled(
" |",
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
for w in &col_widths {
spans.push(Span::styled(format!("{}-|", "-".repeat(*w + 2)), apply_dim(Style::default().fg(Theme::BORDER), dim)));
spans.push(Span::styled(
format!("{}-|", "-".repeat(*w + 2)),
apply_dim(Style::default().fg(Theme::BORDER), dim),
));
}
spans.push(Span::raw("\n"));
}
@@ -259,15 +291,19 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
if line.is_empty() {
continue;
}
let style = diff_line_style(line)
.unwrap_or_else(|| Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG));
let style = diff_line_style(line).unwrap_or_else(|| {
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG)
});
spans.push(Span::styled(format!(" {line}"), style));
}
} else {
let indented = format!(" {}", s.replace('\n', "\n "));
spans.push(Span::styled(
indented,
apply_dim(Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG), dim),
apply_dim(
Style::default().fg(Theme::ACCENT_TEAL).bg(Theme::CODE_BG),
dim,
),
));
}
} else if in_heading {
@@ -322,22 +358,30 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
for span in spans {
let style = span.style;
let text = span.content.as_ref();
let mut current = String::new();
let mut tokens = Vec::new();
for c in text.chars() {
if c == ' ' {
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
tokens.push(" ".to_string());
} else if c == '\n' {
if !current.is_empty() { tokens.push(current.clone()); current.clear(); }
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
tokens.push("\n".to_string());
} else {
current.push(c);
}
}
if !current.is_empty() { tokens.push(current); }
if !current.is_empty() {
tokens.push(current);
}
for token in tokens {
if token == "\n" {
spans_out.push(Span::styled("\n", style));
@@ -385,16 +429,21 @@ fn wrap_spans_to_lines(spans: &[Span<'static>], target_width: usize) -> Vec<Vec<
let text = span.content.as_ref();
let mut current_word = String::new();
let mut tokens = Vec::new();
for c in text.chars() {
if c == ' ' {
if !current_word.is_empty() { tokens.push(current_word.clone()); current_word.clear(); }
if !current_word.is_empty() {
tokens.push(current_word.clone());
current_word.clear();
}
tokens.push(" ".to_string());
} else {
current_word.push(c);
}
}
if !current_word.is_empty() { tokens.push(current_word); }
if !current_word.is_empty() {
tokens.push(current_word);
}
for token in tokens {
if token == " " {
@@ -447,7 +496,9 @@ mod tests {
#[test]
fn dim_true_plain_text_is_dim_italic() {
let spans = render_markdown("hello", 0, true);
let expected = Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC);
let expected = Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC);
assert_eq!(spans[0].style, expected);
}
@@ -455,11 +506,20 @@ mod tests {
fn dim_true_diff_lines_keep_their_own_color() {
let md = "```diff\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context line\n```";
let spans = render_markdown(md, 0, true);
let plus_span = spans.iter().find(|s| s.content.contains("+new line")).expect("plus span present");
let plus_span = spans
.iter()
.find(|s| s.content.contains("+new line"))
.expect("plus span present");
assert_eq!(plus_span.style.fg, Some(Theme::SUCCESS));
let minus_span = spans.iter().find(|s| s.content.contains("-old line")).expect("minus span present");
let minus_span = spans
.iter()
.find(|s| s.content.contains("-old line"))
.expect("minus span present");
assert_eq!(minus_span.style.fg, Some(Theme::ERROR));
let hunk_span = spans.iter().find(|s| s.content.contains("@@")).expect("hunk header span present");
let hunk_span = spans
.iter()
.find(|s| s.content.contains("@@"))
.expect("hunk header span present");
assert_eq!(hunk_span.style.fg, Some(Theme::INFO));
}
@@ -467,7 +527,15 @@ mod tests {
fn dim_true_non_diff_code_block_is_dimmed() {
let md = "```rust\nfn main() {}\n```";
let spans = render_markdown(md, 0, true);
let code_span = spans.iter().find(|s| s.content.contains("fn main")).expect("code span present");
assert_eq!(code_span.style, Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC));
let code_span = spans
.iter()
.find(|s| s.content.contains("fn main"))
.expect("code span present");
assert_eq!(
code_span.style,
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC)
);
}
}
+285 -110
View File
@@ -1,4 +1,9 @@
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Top-level TUI render pipeline: layouts the terminal into chat / input
//! / status regions, dispatches overlay rendering with glassmorphism-style
//! centered panels, and floats toast notifications over the top-right corner.
@@ -15,7 +20,7 @@ pub mod theme;
pub mod workflow;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Style, Modifier};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use ratatui::Frame;
@@ -40,10 +45,7 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
let sidebar_width = if has_workflow { 48 } else { 30 };
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(40),
Constraint::Length(sidebar_width),
])
.constraints([Constraint::Min(40), Constraint::Length(sidebar_width)])
.split(area);
(h_chunks[0], Some(h_chunks[1]))
} else {
@@ -91,11 +93,7 @@ pub fn draw(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
// Panel helpers
// ────────────────────────────────────────────────────────────────────────────
fn render_main_panel(
frame: &mut Frame,
area: Rect,
state: &crate::app::state::rest::AppStateRest,
) {
fn render_main_panel(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
chat::draw_chat(frame, area, state);
}
@@ -109,7 +107,6 @@ fn render_main_panel(
/// - A top accent border strip (colored per variant)
/// - A title line with icon
/// - Content area with proper spacing
#[allow(clippy::too_many_lines)]
fn render_overlay(
frame: &mut Frame,
area: Rect,
@@ -132,7 +129,12 @@ fn render_overlay(
// ── Help ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Help => {
let block = block
.title(Span::styled(" Help ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Help ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::INFO));
let content = crate::resources::HELP_TEXT;
let paragraph = Paragraph::new(content)
@@ -145,7 +147,12 @@ fn render_overlay(
// ── Settings ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Settings => {
let block = block
.title(Span::styled(" Settings ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Settings ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::PRIMARY));
let lines = vec![
Line::from(Span::styled(
@@ -157,13 +164,23 @@ fn render_overlay(
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Max tokens: {}",
state.settings.max_tokens.map_or_else(|| "auto".to_string(), |v| v.to_string())),
format!(
" Max tokens: {}",
state
.settings
.max_tokens
.map_or_else(|| "auto".to_string(), |v| v.to_string())
),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
format!(" Temperature: {}",
state.settings.temperature.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))),
format!(
" Temperature: {}",
state
.settings
.temperature
.map_or_else(|| "auto".to_string(), |v| format!("{v:.1}"))
),
Style::default().fg(Theme::TEXT),
)),
Line::from(Span::styled(
@@ -182,24 +199,39 @@ fn render_overlay(
// ── Bash ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Bash => {
let block = block
.title(Span::styled(" Bash Jobs ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Bash Jobs ",
Style::default()
.fg(Theme::ACCENT_ORANGE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
let lines: Vec<Line> = state.session_runtime.as_ref().map(|r| {
r.bash_jobs.iter().map(|job| {
Line::from(Span::styled(
format!(" [{}] {}{}",
job.id, job.command,
if job.running { "running" } else { "done" },
),
Style::default().fg(Theme::TEXT),
))
}).collect()
}).unwrap_or_default();
let lines: Vec<Line> = state
.session_runtime
.as_ref()
.map(|r| {
r.bash_jobs
.iter()
.map(|job| {
Line::from(Span::styled(
format!(
" [{}] {} — {}",
job.id,
job.command,
if job.running { "running" } else { "done" },
),
Style::default().fg(Theme::TEXT),
))
})
.collect()
})
.unwrap_or_default();
let paragraph = if lines.is_empty() {
Paragraph::new(Line::from(Span::styled(
" No active bash jobs.",
Style::default().fg(Theme::TEXT_DIM),
))).block(block)
)))
.block(block)
} else {
Paragraph::new(lines).block(block)
};
@@ -209,12 +241,19 @@ fn render_overlay(
// ── Quit Confirm ──────────────────────────────────────────────
crate::app::state::types::Overlay::QuitConfirm => {
let block = block
.title(Span::styled(" Quit ", Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Quit ",
Style::default()
.fg(Theme::ERROR)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ERROR));
let lines = vec![
Line::from(Span::styled(
" Are you sure you want to quit?",
Style::default().fg(Theme::ERROR).add_modifier(Modifier::BOLD),
Style::default()
.fg(Theme::ERROR)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
@@ -226,12 +265,15 @@ fn render_overlay(
frame.render_widget(paragraph, overlay_area);
}
// ── Key Input ─────────────────────────────────────────────────
crate::app::state::types::Overlay::KeyInput => {
let block = block
.title(Span::styled(" API Key ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" API Key ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let input_text = &state.input.buffer;
let display = if input_text.is_empty() {
@@ -258,7 +300,12 @@ fn render_overlay(
Line::from(Span::raw("")),
Line::from(vec![
Span::styled(" Key: ", Style::default().fg(Theme::TEXT_DIM)),
Span::styled(masked, Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD)),
Span::styled(
masked,
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
),
]),
];
let paragraph = Paragraph::new(lines).block(block);
@@ -268,12 +315,19 @@ fn render_overlay(
// ── Editor ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Editor => {
let block = block
.title(Span::styled(" Editor ", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Editor ",
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::PRIMARY));
let lines = vec![
Line::from(Span::styled(
" Editor Mode — Ctrl+S save, Esc dismiss",
Style::default().fg(Theme::TEXT_MUTED).add_modifier(Modifier::ITALIC),
Style::default()
.fg(Theme::TEXT_MUTED)
.add_modifier(Modifier::ITALIC),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
@@ -286,7 +340,11 @@ fn render_overlay(
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
format!(" Cursor: pos {} / {}", state.input.cursor, state.input.buffer.len()),
format!(
" Cursor: pos {} / {}",
state.input.cursor,
state.input.buffer.len()
),
Style::default().fg(Theme::TEXT_DIM),
)),
];
@@ -297,7 +355,12 @@ fn render_overlay(
// ── Effort ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Effort => {
let block = block
.title(Span::styled(" Effort Level ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Effort Level ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let levels = crate::app::mode::effort::EFFORT_LEVELS;
let current_idx = crate::app::mode::effort::current_effort(state);
@@ -317,7 +380,9 @@ fn render_overlay(
format!(" {l}")
},
if selected {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
Style::default()
.fg(Theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
},
@@ -330,12 +395,19 @@ fn render_overlay(
// ── MCP ───────────────────────────────────────────────────────
crate::app::state::types::Overlay::Mcp => {
let block = block
.title(Span::styled(" MCP Servers ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" MCP Servers ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::INFO));
let lines = vec![
Line::from(Span::styled(
" MCP Server Management",
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
@@ -359,7 +431,12 @@ fn render_overlay(
// ── Todo ──────────────────────────────────────────────────────
crate::app::state::types::Overlay::Todo => {
let block = block
.title(Span::styled(" Tasks ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Tasks ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let content = if state.misc.todo_content.is_empty() {
" No tasks yet."
@@ -375,7 +452,12 @@ fn render_overlay(
// ── Rewind ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Rewind => {
let block = block
.title(Span::styled(" Rewind ", Style::default().fg(Theme::ACCENT_ORANGE).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Rewind ",
Style::default()
.fg(Theme::ACCENT_ORANGE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_ORANGE));
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
@@ -391,7 +473,11 @@ fn render_overlay(
Style::default().fg(Theme::TEXT_DIM),
)));
} else {
let start = if messages.len() > 8 { messages.len() - 8 } else { 0 };
let start = if messages.len() > 8 {
messages.len() - 8
} else {
0
};
for msg in &messages[start..] {
let role_str = match msg.role {
crate::dto::chat::message::Role::User => "User",
@@ -426,20 +512,27 @@ fn render_overlay(
crate::app::state::types::Overlay::Learning => {
let h_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(40),
Constraint::Percentage(60),
])
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(overlay_area);
let left_block = Block::default()
.title(Span::styled(" Lessons ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Lessons ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG));
let right_block = Block::default()
.title(Span::styled(" Details ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Details ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Theme::BORDER))
.style(Style::default().bg(Theme::BG));
@@ -456,23 +549,33 @@ fn render_overlay(
let is_selected = i == state.misc.selected_index;
let prefix = if is_selected { "" } else { " " };
let (label, style) = match item {
crate::app::mode::learning::LearningItem::Pending { name, .. } => {
(
format!("{prefix}[Pending] {name}"),
if is_selected {
Style::default().fg(Theme::WARNING).bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::WARNING)
},
)
}
crate::app::mode::learning::LearningItem::Stored { name, lifecycle, .. } => {
let status = if lifecycle == "stale" { "Stale" } else { "Active" };
crate::app::mode::learning::LearningItem::Pending { name, .. } => (
format!("{prefix}[Pending] {name}"),
if is_selected {
Style::default()
.fg(Theme::WARNING)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::WARNING)
},
),
crate::app::mode::learning::LearningItem::Stored {
name,
lifecycle,
..
} => {
let status = if lifecycle == "stale" {
"Stale"
} else {
"Active"
};
(
format!("{prefix}[{status}] {name}"),
if is_selected {
Style::default().fg(Theme::TEXT).bg(Theme::HIGHLIGHT_DIM)
Style::default()
.fg(Theme::TEXT)
.bg(Theme::HIGHLIGHT_DIM)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Theme::TEXT)
@@ -507,14 +610,20 @@ fn render_overlay(
if let Some(item) = items.get(selected) {
match item {
crate::app::mode::learning::LearningItem::Pending {
name, content, scope, confidence,
name,
content,
scope,
confidence,
} => {
right_lines.push(Line::from(Span::styled(
" Name:", Style::default().fg(Theme::TEXT_DIM),
" Name:",
Style::default().fg(Theme::TEXT_DIM),
)));
right_lines.push(Line::from(Span::styled(
format!(" {name}"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
@@ -531,7 +640,8 @@ fn render_overlay(
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" Content:", Style::default().fg(Theme::TEXT_DIM),
" Content:",
Style::default().fg(Theme::TEXT_DIM),
)));
for line in content.lines() {
right_lines.push(Line::from(Span::styled(
@@ -546,14 +656,21 @@ fn render_overlay(
)));
}
crate::app::mode::learning::LearningItem::Stored {
name, content, lifecycle, scope, description,
name,
content,
lifecycle,
scope,
description,
} => {
right_lines.push(Line::from(Span::styled(
" Name:", Style::default().fg(Theme::TEXT_DIM),
" Name:",
Style::default().fg(Theme::TEXT_DIM),
)));
right_lines.push(Line::from(Span::styled(
format!(" {name}"),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
right_lines.push(Line::from(Span::raw("")));
let status_color = if lifecycle == "stale" {
@@ -575,7 +692,8 @@ fn render_overlay(
)));
right_lines.push(Line::from(Span::raw("")));
right_lines.push(Line::from(Span::styled(
" Content:", Style::default().fg(Theme::TEXT_DIM),
" Content:",
Style::default().fg(Theme::TEXT_DIM),
)));
for line in content.lines() {
right_lines.push(Line::from(Span::styled(
@@ -605,19 +723,32 @@ fn render_overlay(
// ── Usage ────────────────────────────────────────────────────
crate::app::state::types::Overlay::Usage => {
let block = block
.title(Span::styled(" Usage ", Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Usage ",
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::INFO));
let runtime = state.session_runtime.as_ref();
let now_ms = chrono::Utc::now().timestamp_millis();
let summary = runtime.map(|r| sidebar::compute_usage_summary(&r.usage, r.session_start, now_ms));
let (edit_count, lesson_count, review_count, consec_empty) = runtime
.map_or((0, 0, 0, 0), |r| {
(r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews)
let summary =
runtime.map(|r| sidebar::compute_usage_summary(&r.usage, r.session_start, now_ms));
let (edit_count, lesson_count, review_count, consec_empty) =
runtime.map_or((0, 0, 0, 0), |r| {
(
r.edit_count,
r.lesson_count,
r.review_count,
r.consecutive_empty_reviews,
)
});
let mut lines = vec![
Line::from(Span::styled(
" Token Usage",
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
];
@@ -632,7 +763,9 @@ fn render_overlay(
)));
lines.push(Line::from(Span::styled(
format!(" Total: {} tokens", s.total_tokens),
Style::default().fg(Theme::TEXT).add_modifier(Modifier::BOLD),
Style::default()
.fg(Theme::TEXT)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" API calls: {}", s.api_calls),
@@ -647,7 +780,9 @@ fn render_overlay(
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
" Activity",
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(Span::styled(
format!(" Edits: {edit_count}"),
@@ -662,15 +797,27 @@ fn render_overlay(
Style::default().fg(Theme::TEXT_MUTED),
)));
lines.push(Line::from(Span::styled(
format!(" Empty reviews: {}",
if consec_empty > 3 { format!("{consec_empty}") } else { consec_empty.to_string() },
format!(
" Empty reviews: {}",
if consec_empty > 3 {
format!("{consec_empty}")
} else {
consec_empty.to_string()
},
),
Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::TEXT_DIM }),
Style::default().fg(if consec_empty > 3 {
Theme::WARNING
} else {
Theme::TEXT_DIM
}),
)));
if let Some(s) = &summary {
lines.push(Line::from(Span::raw("")));
lines.push(Line::from(Span::styled(
format!(" Session: {}h {}m {}s", s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds),
format!(
" Session: {}h {}m {}s",
s.elapsed_hours, s.elapsed_minutes, s.elapsed_seconds
),
Style::default().fg(Theme::TEXT_DIM),
)));
}
@@ -681,25 +828,39 @@ fn render_overlay(
// ── Loading ──────────────────────────────────────────────────
crate::app::state::types::Overlay::Loading => {
let block = block
.title(Span::styled(" Loading ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Loading ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let spinner = ["", "", "", "", "", "", "", "", "", ""];
let frame_idx = (state.misc.tick_count as usize) % spinner.len();
let content = format!(" {} Processing, please wait...", spinner[frame_idx]);
let paragraph = Paragraph::new(content)
.block(block);
let paragraph = Paragraph::new(content).block(block);
frame.render_widget(paragraph, overlay_area);
}
// ── Model Selector ───────────────────────────────────────────
crate::app::state::types::Overlay::ModelSelector => {
let block = block
.title(Span::styled(" Model Selector ", Style::default().fg(Theme::ACCENT_PURPLE).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Model Selector ",
Style::default()
.fg(Theme::ACCENT_PURPLE)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::ACCENT_PURPLE));
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
format!(" Current: {} / {}", state.settings.provider, state.settings.model),
Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD),
format!(
" Current: {} / {}",
state.settings.provider, state.settings.model
),
Style::default()
.fg(Theme::INFO)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::raw("")),
Line::from(Span::styled(
@@ -716,7 +877,9 @@ fn render_overlay(
let model_str = cfg.default_model.as_deref().unwrap_or("(any)");
let label = format!("{prefix}{name} ({model_str})");
let style = if is_current {
Style::default().fg(Theme::HIGHLIGHT).add_modifier(Modifier::BOLD)
Style::default()
.fg(Theme::HIGHLIGHT)
.add_modifier(Modifier::BOLD)
} else if is_selected {
Style::default().fg(Theme::BG).bg(Theme::HIGHLIGHT)
} else {
@@ -736,7 +899,12 @@ fn render_overlay(
// ── Clear Confirm ────────────────────────────────────────────
crate::app::state::types::Overlay::ClearConfirm => {
let block = block
.title(Span::styled(" Clear Transcript ", Style::default().fg(Theme::WARNING).add_modifier(Modifier::BOLD)))
.title(Span::styled(
" Clear Transcript ",
Style::default()
.fg(Theme::WARNING)
.add_modifier(Modifier::BOLD),
))
.border_style(Style::default().fg(Theme::WARNING));
let lines = vec![
Line::from(Span::styled(
@@ -763,11 +931,7 @@ fn render_overlay(
///
/// The bar has a subtle top border, a `` prompt, the user's buffer with
/// a highlighted cursor position, and placeholder text when empty.
fn render_input_bar(
frame: &mut Frame,
area: Rect,
state: &crate::app::state::rest::AppStateRest,
) {
fn render_input_bar(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) {
// ── Autocomplete dropdown ────────────────────────────────────────────
if state.input.autocomplete_visible && !state.input.autocomplete_candidates.is_empty() {
let n = state.input.autocomplete_candidates.len().min(10) as u16;
@@ -793,7 +957,13 @@ fn render_input_bar(
let mut lines: Vec<Line> = Vec::new();
let selected = state.input.autocomplete_idx;
for (i, candidate) in state.input.autocomplete_candidates.iter().enumerate().take(10) {
for (i, candidate) in state
.input
.autocomplete_candidates
.iter()
.enumerate()
.take(10)
{
let prefix = if i == selected { "" } else { " " };
let style = if i == selected {
Style::default()
@@ -821,7 +991,9 @@ fn render_input_bar(
let prompt = Span::styled(
" ",
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
Style::default()
.fg(Theme::PRIMARY)
.add_modifier(Modifier::BOLD),
);
let mut spans = vec![prompt];
@@ -829,16 +1001,14 @@ fn render_input_bar(
if input_text.is_empty() {
spans.push(Span::styled(
"Type a message or /command...",
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC),
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
));
} else {
let (before, after) = input_text.split_at(cursor_pos);
spans.push(Span::raw(before.to_string()));
let cursor_char = if after.is_empty() {
" "
} else {
&after[..1]
};
let cursor_char = if after.is_empty() { " " } else { &after[..1] };
// Cursor highlight
spans.push(Span::styled(
cursor_char,
@@ -868,7 +1038,10 @@ fn render_input_bar(
/// left border and a subtle background.
fn render_toasts(frame: &mut Frame, state: &crate::app::state::rest::AppStateRest) {
let now_ms = chrono::Utc::now().timestamp_millis();
let active: Vec<&crate::app::state::types::Toast> = state.misc.toasts.iter()
let active: Vec<&crate::app::state::types::Toast> = state
.misc
.toasts
.iter()
.filter(|t| !t.expired(now_ms))
.collect();
if active.is_empty() {
@@ -957,7 +1130,9 @@ pub(crate) fn split_for_display<T>(items: &[T], max_visible: usize) -> (&[T], us
pub(crate) fn overflow_hint_line(hidden: usize, command: &str) -> Line<'static> {
Line::from(Span::styled(
format!(" +{hidden} more — {command}"),
Style::default().fg(Theme::TEXT_DIM).add_modifier(Modifier::ITALIC),
Style::default()
.fg(Theme::TEXT_DIM)
.add_modifier(Modifier::ITALIC),
))
}