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

Some files were not shown because too many files have changed in this diff Show More