feat(tui): implement agent turn engine for background processing and enhance input handling
This commit is contained in:
Generated
+1
@@ -4968,6 +4968,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"webbrowser",
|
||||||
"zesdex-application",
|
"zesdex-application",
|
||||||
"zesdex-domain",
|
"zesdex-domain",
|
||||||
"zesdex-infrastructure",
|
"zesdex-infrastructure",
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
|
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use tracing;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let store = zesdex_domain::core::Store::new();
|
let store = zesdex_domain::core::Store::new();
|
||||||
let sessions_dir = store.base_dir.join("sessions");
|
let sessions_dir = store.base_dir.join("sessions");
|
||||||
|
|
||||||
if !sessions_dir.exists() {
|
if !sessions_dir.exists() {
|
||||||
eprintln!("No sessions directory found, nothing to migrate");
|
tracing::info!("No sessions directory found, nothing to migrate");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,16 +28,16 @@ fn main() -> anyhow::Result<()> {
|
|||||||
match migrate_session_msglog(&path) {
|
match migrate_session_msglog(&path) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
migrated += 1;
|
migrated += 1;
|
||||||
eprintln!("Migrated session: {:?}", path.file_name());
|
tracing::info!("Migrated session: {:?}", path.file_name());
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
eprintln!("Failed to migrate session {:?}: {e}", path.file_name());
|
tracing::error!("Failed to migrate session {:?}: {e}", path.file_name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("Migration complete: {migrated} succeeded, {failed} failed");
|
tracing::info!("Migration complete: {migrated} succeeded, {failed} failed");
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
anyhow::bail!("{failed} session(s) failed to migrate");
|
anyhow::bail!("{failed} session(s) failed to migrate");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
//! configuration files plus a seed session for development/testing.
|
//! configuration files plus a seed session for development/testing.
|
||||||
//! Invoked as `cargo run --bin seed`.
|
//! Invoked as `cargo run --bin seed`.
|
||||||
|
|
||||||
|
use tracing;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let store = zesdex_domain::core::Store::new();
|
let store = zesdex_domain::core::Store::new();
|
||||||
store.ensure_dirs()?;
|
store.ensure_dirs()?;
|
||||||
@@ -18,9 +20,9 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let f = std::fs::File::open(&tmp)?;
|
let f = std::fs::File::open(&tmp)?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
std::fs::rename(&tmp, settings_path)?;
|
std::fs::rename(&tmp, settings_path)?;
|
||||||
println!("Default settings created");
|
tracing::info!("Default settings created");
|
||||||
} else {
|
} else {
|
||||||
println!("Settings already exist, skipping");
|
tracing::info!("Settings already exist, skipping");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create default app config if not present
|
// Create default app config if not present
|
||||||
@@ -33,15 +35,15 @@ fn main() -> anyhow::Result<()> {
|
|||||||
let f = std::fs::File::open(&tmp)?;
|
let f = std::fs::File::open(&tmp)?;
|
||||||
f.sync_all()?;
|
f.sync_all()?;
|
||||||
std::fs::rename(&tmp, config_path)?;
|
std::fs::rename(&tmp, config_path)?;
|
||||||
println!("Default app_config created");
|
tracing::info!("Default app_config created");
|
||||||
} else {
|
} else {
|
||||||
println!("App config already exists, skipping");
|
tracing::info!("App config already exists, skipping");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directories
|
// Create data directories
|
||||||
std::fs::create_dir_all(&store.memory_dir)?;
|
std::fs::create_dir_all(&store.memory_dir)?;
|
||||||
std::fs::create_dir_all(&store.session_images_dir)?;
|
std::fs::create_dir_all(&store.session_images_dir)?;
|
||||||
println!("All store directories verified");
|
tracing::info!("All store directories verified");
|
||||||
|
|
||||||
// Create a seed session
|
// Create a seed session
|
||||||
let session_id = uuid::Uuid::new_v4().to_string();
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||||||
@@ -53,7 +55,7 @@ fn main() -> anyhow::Result<()> {
|
|||||||
use zesdex_domain::SessionRepository;
|
use zesdex_domain::SessionRepository;
|
||||||
let repo = zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
|
let repo = zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
|
||||||
repo.save_session(&store.base_dir, &session)?;
|
repo.save_session(&store.base_dir, &session)?;
|
||||||
println!("Seed session created: id={session_id}");
|
tracing::info!("Seed session created: id={session_id}");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-58
@@ -4,39 +4,59 @@
|
|||||||
//! to the requested interface: TUI (default), daemon (background IPC),
|
//! to the requested interface: TUI (default), daemon (background IPC),
|
||||||
//! API server (REST), WebSocket server, gRPC server, or Web frontend.
|
//! API server (REST), WebSocket server, gRPC server, or Web frontend.
|
||||||
//!
|
//!
|
||||||
//! # CLI flags
|
//! CLI flags are parsed via clap; run with `--help` for details.
|
||||||
//!
|
|
||||||
//! | Flag | Description |
|
|
||||||
//! |------|-------------|
|
|
||||||
//! | `--daemon` | Run as background daemon with IPC socket |
|
|
||||||
//! | `--attach <id>` | Attach TUI client to a running daemon |
|
|
||||||
//! | `--api` | Run REST API server |
|
|
||||||
//! | `--api-port <port>` | REST API port (default 8080) |
|
|
||||||
//! | `--ws` | Run WebSocket server |
|
|
||||||
//! | `--ws-port <port>` | WebSocket port (default 8081) |
|
|
||||||
//! | `--grpc` | Run gRPC server |
|
|
||||||
//! | `--grpc-port <port>` | gRPC port (default 50051) |
|
|
||||||
//! | `--web` | Serve web frontend |
|
|
||||||
//! | `--version` | Print version and exit |
|
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
use clap::Parser;
|
||||||
let args: Vec<String> = std::env::args().collect();
|
|
||||||
let is_daemon = args.iter().any(|a| a == "--daemon");
|
|
||||||
let is_api = args.iter().any(|a| a == "--api");
|
|
||||||
let is_ws = args.iter().any(|a| a == "--ws");
|
|
||||||
let is_grpc = args.iter().any(|a| a == "--grpc");
|
|
||||||
let is_web = args.iter().any(|a| a == "--web");
|
|
||||||
let attach_session = args
|
|
||||||
.iter()
|
|
||||||
.position(|a| a == "--attach")
|
|
||||||
.and_then(|i| args.get(i + 1).cloned());
|
|
||||||
|
|
||||||
if args.iter().any(|a| a == "--version") {
|
/// Zesdex — autonomous AI coding agent.
|
||||||
println!("Zesdex version {}", env!("CARGO_PKG_VERSION"));
|
#[derive(Parser, Debug)]
|
||||||
return Ok(());
|
#[command(name = "zesdex", version, about = "Autonomous AI coding agent with TUI")]
|
||||||
}
|
struct Cli {
|
||||||
|
/// Run as background daemon with IPC socket
|
||||||
|
#[arg(long)]
|
||||||
|
daemon: bool,
|
||||||
|
|
||||||
|
/// Attach TUI client to a running daemon session
|
||||||
|
#[arg(long)]
|
||||||
|
attach: Option<String>,
|
||||||
|
|
||||||
|
/// Run REST API server
|
||||||
|
#[arg(long)]
|
||||||
|
api: bool,
|
||||||
|
|
||||||
|
/// REST API port
|
||||||
|
#[arg(long, default_value_t = 8080)]
|
||||||
|
api_port: u16,
|
||||||
|
|
||||||
|
/// Run WebSocket server
|
||||||
|
#[arg(long)]
|
||||||
|
ws: bool,
|
||||||
|
|
||||||
|
/// WebSocket port
|
||||||
|
#[arg(long, default_value_t = 8081)]
|
||||||
|
ws_port: u16,
|
||||||
|
|
||||||
|
/// Run gRPC server
|
||||||
|
#[arg(long)]
|
||||||
|
grpc: bool,
|
||||||
|
|
||||||
|
/// gRPC port
|
||||||
|
#[arg(long, default_value_t = 50051)]
|
||||||
|
grpc_port: u16,
|
||||||
|
|
||||||
|
/// Serve web frontend
|
||||||
|
#[arg(long)]
|
||||||
|
web: bool,
|
||||||
|
|
||||||
|
/// Web frontend port
|
||||||
|
#[arg(long, default_value_t = 3000)]
|
||||||
|
web_port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
// ── Setup logging ────────────────────────────────────────────────────
|
// ── Setup logging ────────────────────────────────────────────────────
|
||||||
let log_dir = dirs::data_dir()
|
let log_dir = dirs::data_dir()
|
||||||
@@ -67,11 +87,11 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// ── Dispatch to interface ────────────────────────────────────────────
|
// ── Dispatch to interface ────────────────────────────────────────────
|
||||||
// Validate mutually exclusive flags
|
// Validate mutually exclusive flags
|
||||||
let mode_count = [is_daemon, is_api, is_ws, is_grpc, is_web]
|
let mode_count = [cli.daemon, cli.api, cli.ws, cli.grpc, cli.web]
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&b| b)
|
.filter(|&&b| b)
|
||||||
.count()
|
.count()
|
||||||
+ if attach_session.is_some() { 1 } else { 0 };
|
+ if cli.attach.is_some() { 1 } else { 0 };
|
||||||
|
|
||||||
if mode_count > 1 {
|
if mode_count > 1 {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@@ -79,24 +99,24 @@ fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_daemon {
|
if cli.daemon {
|
||||||
tracing::info!("starting in daemon mode");
|
tracing::info!("starting in daemon mode");
|
||||||
zesdex_daemon::server::run_daemon()?;
|
zesdex_daemon::server::run_daemon()?;
|
||||||
} else if let Some(session_id) = attach_session {
|
} else if let Some(session_id) = cli.attach {
|
||||||
tracing::info!("starting in attach mode for session {session_id}");
|
tracing::info!("starting in attach mode for session {session_id}");
|
||||||
zesdex_daemon::client::run_attach(&session_id)?;
|
zesdex_daemon::client::run_attach(&session_id)?;
|
||||||
} else if is_api {
|
} else if cli.api {
|
||||||
tracing::info!("starting in API server mode");
|
tracing::info!("starting in API server mode");
|
||||||
run_api_server(&args)?;
|
run_api_server(cli.api_port)?;
|
||||||
} else if is_ws {
|
} else if cli.ws {
|
||||||
tracing::info!("starting in WebSocket server mode");
|
tracing::info!("starting in WebSocket server mode");
|
||||||
run_ws_server()?;
|
run_ws_server(cli.ws_port)?;
|
||||||
} else if is_grpc {
|
} else if cli.grpc {
|
||||||
tracing::info!("starting in gRPC server mode");
|
tracing::info!("starting in gRPC server mode");
|
||||||
run_grpc_server()?;
|
run_grpc_server(cli.grpc_port)?;
|
||||||
} else if is_web {
|
} else if cli.web {
|
||||||
tracing::info!("starting in web server mode");
|
tracing::info!("starting in web server mode");
|
||||||
run_web_server()?;
|
run_web_server(cli.web_port)?;
|
||||||
} else {
|
} else {
|
||||||
// Default: run TUI single-process mode
|
// Default: run TUI single-process mode
|
||||||
tracing::info!("starting in TUI single-process mode");
|
tracing::info!("starting in TUI single-process mode");
|
||||||
@@ -108,19 +128,11 @@ fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
/// Run the TUI in single-process mode (TUI + agent in one process).
|
/// Run the TUI in single-process mode (TUI + agent in one process).
|
||||||
fn run_tui_single_process() -> anyhow::Result<()> {
|
fn run_tui_single_process() -> anyhow::Result<()> {
|
||||||
// Import and run the TUI's single-process entry point
|
|
||||||
zesdex_tui::run_single_process()
|
zesdex_tui::run_single_process()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the REST API server.
|
/// Run the REST API server.
|
||||||
fn run_api_server(args: &[String]) -> anyhow::Result<()> {
|
fn run_api_server(port: u16) -> anyhow::Result<()> {
|
||||||
let port = args
|
|
||||||
.iter()
|
|
||||||
.position(|a| a == "--api-port")
|
|
||||||
.and_then(|i| args.get(i + 1))
|
|
||||||
.and_then(|s| s.parse::<u16>().ok())
|
|
||||||
.unwrap_or(8080);
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
let store = zesdex_domain::core::Store::new();
|
let store = zesdex_domain::core::Store::new();
|
||||||
@@ -133,8 +145,7 @@ fn run_api_server(args: &[String]) -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
let app = zesdex_api::build_router(state);
|
let app = zesdex_api::build_router(state);
|
||||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
tracing::info!("REST API server listening on {addr}");
|
tracing::info!("REST API server listening on http://{addr}/api/v1/health");
|
||||||
println!("REST API server listening on http://{addr}/api/v1/health");
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
Ok::<_, anyhow::Error>(())
|
Ok::<_, anyhow::Error>(())
|
||||||
@@ -143,22 +154,22 @@ fn run_api_server(args: &[String]) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run the WebSocket server.
|
/// Run the WebSocket server.
|
||||||
fn run_ws_server() -> anyhow::Result<()> {
|
fn run_ws_server(port: u16) -> anyhow::Result<()> {
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
rt.block_on(async { zesdex_ws::run_server(8081).await })?;
|
rt.block_on(async { zesdex_ws::run_server(port).await })?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run the gRPC server.
|
/// Run the gRPC server.
|
||||||
fn run_grpc_server() -> anyhow::Result<()> {
|
fn run_grpc_server(port: u16) -> anyhow::Result<()> {
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
rt.block_on(async { zesdex_grpc::run_server(50051).await })?;
|
rt.block_on(async { zesdex_grpc::run_server(port).await })?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serve the web frontend.
|
/// Serve the web frontend.
|
||||||
fn run_web_server() -> anyhow::Result<()> {
|
fn run_web_server(port: u16) -> anyhow::Result<()> {
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
let rt = tokio::runtime::Runtime::new()?;
|
||||||
rt.block_on(async { zesdex_web::run_server(3000, None).await })?;
|
rt.block_on(async { zesdex_web::run_server(port, None).await })?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,8 +55,12 @@ impl LoopbackServer {
|
|||||||
Missing authorization code."
|
Missing authorization code."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = stream.write_all(response.as_bytes());
|
if let Err(e) = stream.write_all(response.as_bytes()) {
|
||||||
let _ = stream.flush();
|
tracing::warn!("OAuth loopback write error: {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = stream.flush() {
|
||||||
|
tracing::warn!("OAuth loopback flush error: {e}");
|
||||||
|
}
|
||||||
if !state_ok {
|
if !state_ok {
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidData,
|
std::io::ErrorKind::InvalidData,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
use super::job::BashJob;
|
use super::job::BashJob;
|
||||||
|
|
||||||
/// Central registry of all running background bash jobs.
|
/// Central registry of all running background bash jobs.
|
||||||
@@ -37,7 +39,13 @@ impl BashControl {
|
|||||||
|
|
||||||
/// List all active jobs.
|
/// List all active jobs.
|
||||||
pub fn list(&self) -> Vec<(String, String, bool)> {
|
pub fn list(&self) -> Vec<(String, String, bool)> {
|
||||||
let mut guard = self.jobs.lock().unwrap();
|
let mut guard = match self.jobs.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
error!("bgbash jobs mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
guard.retain(|_, j| j.is_running());
|
guard.retain(|_, j| j.is_running());
|
||||||
guard
|
guard
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use std::process::{Child, Command, Stdio};
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
/// A handle to a spawned background bash job.
|
/// A handle to a spawned background bash job.
|
||||||
pub struct BashJob {
|
pub struct BashJob {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -34,7 +36,13 @@ pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
|
|||||||
// Spawn a monitor thread (in production this would use an async task)
|
// Spawn a monitor thread (in production this would use an async task)
|
||||||
let job_clone = Arc::clone(&job);
|
let job_clone = Arc::clone(&job);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let mut guard = job_clone.process.lock().unwrap();
|
let mut guard = match job_clone.process.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
error!("bgbash job mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(ref mut child) = *guard {
|
if let Some(ref mut child) = *guard {
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ pub fn check_dangerous_pattern(tool_name: &str, args: &serde_json::Value) -> Opt
|
|||||||
return Some(format!("Deleting '{}' is too dangerous", path));
|
return Some(format!("Deleting '{}' is too dangerous", path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {
|
||||||
|
tracing::debug!("no guard pattern registered for tool: {tool_name}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,9 @@ impl LlmClient {
|
|||||||
StreamEvent::Token(_) | StreamEvent::Reasoning(_) => {
|
StreamEvent::Token(_) | StreamEvent::Reasoning(_) => {
|
||||||
captured_content = true;
|
captured_content = true;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {
|
||||||
|
tracing::debug!("unhandled stream event type in wrapped closure");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
on_event(event)
|
on_event(event)
|
||||||
};
|
};
|
||||||
@@ -373,7 +375,9 @@ impl LlmClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {
|
||||||
|
tracing::debug!("unhandled stream event in apply_event: {event:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +445,10 @@ impl LlmClient {
|
|||||||
turn.done_received = true;
|
turn.done_received = true;
|
||||||
return Ok((turn.build_assistant_message(), usage));
|
return Ok((turn.build_assistant_message(), usage));
|
||||||
}
|
}
|
||||||
_ => turn.apply_event(&event),
|
other => {
|
||||||
|
tracing::debug!("unhandled stream event type: {other:?}");
|
||||||
|
turn.apply_event(other);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ impl LspClient {
|
|||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
let stdin = child.stdin.take().unwrap();
|
let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("no stdin on LSP process"))?;
|
||||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
let stdout = BufReader::new(child.stdout.take().ok_or_else(|| anyhow::anyhow!("no stdout on LSP process"))?);
|
||||||
|
|
||||||
info!("LSP client spawned: {command}");
|
info!("LSP client spawned: {command}");
|
||||||
Ok(LspClient {
|
Ok(LspClient {
|
||||||
@@ -48,7 +48,13 @@ impl LspClient {
|
|||||||
|
|
||||||
/// Send a JSON-RPC request and read the response.
|
/// Send a JSON-RPC request and read the response.
|
||||||
pub fn send_request(&self, method: &str, params: &Value) -> Result<Value> {
|
pub fn send_request(&self, method: &str, params: &Value) -> Result<Value> {
|
||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = match self.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("LSP client mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
inner.request_id += 1;
|
inner.request_id += 1;
|
||||||
let request = serde_json::json!({
|
let request = serde_json::json!({
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
@@ -92,8 +98,12 @@ impl LspClient {
|
|||||||
/// Gracefully shut down the server.
|
/// Gracefully shut down the server.
|
||||||
pub fn shutdown(&self) -> Result<()> {
|
pub fn shutdown(&self) -> Result<()> {
|
||||||
let null = Value::Null;
|
let null = Value::Null;
|
||||||
let _ = self.send_request("shutdown", &null);
|
if let Err(e) = self.send_request("shutdown", &null) {
|
||||||
let _ = self.send_request("exit", &null);
|
tracing::warn!("LSP shutdown error: {e}");
|
||||||
|
}
|
||||||
|
if let Err(e) = self.send_request("exit", &null) {
|
||||||
|
tracing::warn!("LSP exit error: {e}");
|
||||||
|
}
|
||||||
if let Ok(mut inner) = self.inner.lock() {
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
let _ = inner.process.wait();
|
let _ = inner.process.wait();
|
||||||
}
|
}
|
||||||
@@ -105,7 +115,9 @@ impl LspClient {
|
|||||||
impl Drop for LspClient {
|
impl Drop for LspClient {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Ok(mut inner) = self.inner.lock() {
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
let _ = inner.process.kill();
|
if let Err(e) = inner.process.kill() {
|
||||||
|
tracing::warn!("LSP process kill error: {e}");
|
||||||
|
}
|
||||||
let _ = inner.process.wait();
|
let _ = inner.process.wait();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ impl McpTransport {
|
|||||||
|
|
||||||
pub fn stop(&mut self) -> anyhow::Result<()> {
|
pub fn stop(&mut self) -> anyhow::Result<()> {
|
||||||
if let Some(mut child) = self.process.take() {
|
if let Some(mut child) = self.process.take() {
|
||||||
let _ = child.kill();
|
if let Err(e) = child.kill() {
|
||||||
|
tracing::warn!("MCP transport kill error: {e}");
|
||||||
|
}
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -33,7 +35,9 @@ impl McpTransport {
|
|||||||
impl Drop for McpTransport {
|
impl Drop for McpTransport {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Some(mut child) = self.process.take() {
|
if let Some(mut child) = self.process.take() {
|
||||||
let _ = child.kill();
|
if let Err(e) = child.kill() {
|
||||||
|
tracing::warn!("MCP transport kill error: {e}");
|
||||||
|
}
|
||||||
let _ = child.wait();
|
let _ = child.wait();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,8 +78,25 @@ 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 = crate::tools::arg_str(args, "job_id")?;
|
let job_id = crate::tools::arg_str(args, "job_id")?;
|
||||||
// In production, look up and kill the job in BashControl
|
info!("bash_kill called for job: {job_id}");
|
||||||
Ok(format!("Killed background job '{}'", _job_id))
|
// Try to kill by PID (if job_id is numeric) or by process name
|
||||||
|
if let Ok(pid) = job_id.parse::<u32>() {
|
||||||
|
use std::process::Command;
|
||||||
|
match Command::new("kill").arg(pid.to_string()).output() {
|
||||||
|
Ok(output) if output.status.success() => {
|
||||||
|
Ok(format!("Killed background job '{job_id}' (PID {pid})"))
|
||||||
|
}
|
||||||
|
Ok(output) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
Ok(format!("Failed to kill job '{job_id}': {stderr}"))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
Ok(format!("Failed to kill job '{job_id}': {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(format!("Invalid job ID '{job_id}' — expected numeric PID"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ impl Tool for LspCompletion {
|
|||||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
|
||||||
let manager = ctx.lsp_manager.lock().unwrap();
|
let manager = match ctx.lsp_manager.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(client) = manager.get_client(&language) {
|
if let Some(client) = manager.get_client(&language) {
|
||||||
let result = client.send_request("textDocument/completion", &json!({
|
let result = client.send_request("textDocument/completion", &json!({
|
||||||
"textDocument": { "uri": format!("file://{}", path) },
|
"textDocument": { "uri": format!("file://{}", path) },
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ impl Tool for LspConnect {
|
|||||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut manager = ctx.lsp_manager.lock().unwrap();
|
let mut manager = match ctx.lsp_manager.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
manager.start(&language, &command, &extra_args)?;
|
manager.start(&language, &command, &extra_args)?;
|
||||||
|
|
||||||
Ok(format!("Connected LSP for '{language}'"))
|
Ok(format!("Connected LSP for '{language}'"))
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ impl Tool for LspDefinition {
|
|||||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
|
||||||
let manager = ctx.lsp_manager.lock().unwrap();
|
let manager = match ctx.lsp_manager.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(client) = manager.get_client(&language) {
|
if let Some(client) = manager.get_client(&language) {
|
||||||
let result = client.send_request("textDocument/definition", &json!({
|
let result = client.send_request("textDocument/definition", &json!({
|
||||||
"textDocument": { "uri": format!("file://{}", path) },
|
"textDocument": { "uri": format!("file://{}", path) },
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ impl Tool for LspDiagnostics {
|
|||||||
let language = crate::tools::arg_str(args, "language")?;
|
let language = crate::tools::arg_str(args, "language")?;
|
||||||
let path = crate::tools::arg_str(args, "path")?;
|
let path = crate::tools::arg_str(args, "path")?;
|
||||||
|
|
||||||
let manager = ctx.lsp_manager.lock().unwrap();
|
let manager = match ctx.lsp_manager.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(client) = manager.get_client(&language) {
|
if let Some(client) = manager.get_client(&language) {
|
||||||
let result = client.send_request("textDocument/diagnostic", &json!({
|
let result = client.send_request("textDocument/diagnostic", &json!({
|
||||||
"textDocument": { "uri": format!("file://{}", path) }
|
"textDocument": { "uri": format!("file://{}", path) }
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ impl Tool for LspDisconnect {
|
|||||||
|
|
||||||
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
let language = crate::tools::arg_str(args, "language")?;
|
let language = crate::tools::arg_str(args, "language")?;
|
||||||
let _manager = ctx.lsp_manager.lock().unwrap();
|
let _manager = match ctx.lsp_manager.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
Ok(format!("Disconnected LSP for '{language}'"))
|
Ok(format!("Disconnected LSP for '{language}'"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ impl Tool for LspHover {
|
|||||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
|
||||||
let manager = ctx.lsp_manager.lock().unwrap();
|
let manager = match ctx.lsp_manager.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(client) = manager.get_client(&language) {
|
if let Some(client) = manager.get_client(&language) {
|
||||||
let result = client.send_request("textDocument/hover", &json!({
|
let result = client.send_request("textDocument/hover", &json!({
|
||||||
"textDocument": { "uri": format!("file://{}", path) },
|
"textDocument": { "uri": format!("file://{}", path) },
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ impl Tool for LspReferences {
|
|||||||
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
let line = args.get("line").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
let character = args.get("character").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
|
||||||
let manager = ctx.lsp_manager.lock().unwrap();
|
let manager = match ctx.lsp_manager.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(poisoned) => {
|
||||||
|
tracing::error!("LSP manager mutex poisoned, recovering");
|
||||||
|
poisoned.into_inner()
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Some(client) = manager.get_client(&language) {
|
if let Some(client) = manager.get_client(&language) {
|
||||||
let result = client.send_request("textDocument/references", &json!({
|
let result = client.send_request("textDocument/references", &json!({
|
||||||
"textDocument": { "uri": format!("file://{}", path) },
|
"textDocument": { "uri": format!("file://{}", path) },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use crate::tools::{Tool, ToolCtx};
|
use crate::tools::{Tool, ToolCtx};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
pub struct PlanEnter;
|
pub struct PlanEnter;
|
||||||
|
|
||||||
@@ -51,11 +52,28 @@ impl Tool for PlanReady {
|
|||||||
fn parameters(&self) -> Value {
|
fn parameters(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {
|
||||||
|
"plan": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The final plan content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["plan"]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
|
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||||
Ok("Plan is ready. Starting execution.".to_string())
|
let plan_content = crate::tools::arg_str(args, "plan")?;
|
||||||
|
info!("plan ready: {} chars", plan_content.len());
|
||||||
|
// Persist the plan to session directory for reference
|
||||||
|
let plan_dir = ctx.session_dir.join("plans");
|
||||||
|
if std::fs::create_dir_all(&plan_dir).is_ok() {
|
||||||
|
let filename = format!("plan-{}.md", chrono::Utc::now().format("%Y%m%d_%H%M%S"));
|
||||||
|
let path = plan_dir.join(&filename);
|
||||||
|
let _ = std::fs::write(&path, &plan_content);
|
||||||
|
Ok(format!("Plan saved to {filename}. Starting execution."))
|
||||||
|
} else {
|
||||||
|
Ok("Plan is ready. Starting execution.".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ pub fn is_credential_path(path: &str) -> bool {
|
|||||||
|
|
||||||
/// Check whether a command reads credential files.
|
/// Check whether a command reads credential files.
|
||||||
pub fn check_credential_read(cmd: &str) -> Vec<String> {
|
pub fn check_credential_read(cmd: &str) -> Vec<String> {
|
||||||
let re = Regex::new(r#"(?i)(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\s+(~?/[\w/.@-]+)"#).unwrap();
|
let re = Regex::new(r#"(?i)(?:cat|head|tail|less|more|vim?|nano|xdg-open|open|type|echo)\s+(~?/[\w/.@-]+)"#)
|
||||||
|
.expect("hardcoded credential-read regex is valid");
|
||||||
let mut findings = Vec::new();
|
let mut findings = Vec::new();
|
||||||
for cap in re.captures_iter(cmd) {
|
for cap in re.captures_iter(cmd) {
|
||||||
let path = cap.get(1).map(|m| m.as_str()).unwrap_or("");
|
let path = cap.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ sha2.workspace = true
|
|||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
webbrowser.workspace = true
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use tracing::info;
|
||||||
|
use webbrowser;
|
||||||
use zesdex_infrastructure::ipc::conn::Connection;
|
use zesdex_infrastructure::ipc::conn::Connection;
|
||||||
use zesdex_infrastructure::ipc::protocol::{
|
use zesdex_infrastructure::ipc::protocol::{
|
||||||
ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry,
|
ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry,
|
||||||
@@ -179,7 +181,8 @@ fn handle_quit_confirm(state: &mut AppStateRest) {
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_resize(state: &mut AppStateRest, _w: u16) {
|
fn handle_resize(state: &mut AppStateRest, w: u16) {
|
||||||
|
tracing::debug!("terminal resize to width={}", w);
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +377,9 @@ fn handle_system_note(state: &mut AppStateRest, message: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_model_list(state: &mut AppStateRest) {
|
fn handle_model_list(state: &mut AppStateRest) {
|
||||||
handle_open_overlay(state, Overlay::ModelSelector);
|
info!("opening model selector");
|
||||||
|
state.misc.overlay = Overlay::ModelSelector;
|
||||||
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_abort_turn(state: &mut AppStateRest) {
|
fn handle_abort_turn(state: &mut AppStateRest) {
|
||||||
@@ -386,36 +391,62 @@ fn handle_abort_turn(state: &mut AppStateRest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn handle_compact(state: &mut AppStateRest) {
|
fn handle_compact(state: &mut AppStateRest) {
|
||||||
// Placeholder — compaction logic is delegated to the agent runtime.
|
tracing::info!("compacting conversation");
|
||||||
state.toast_info("Compacting conversation...");
|
const KEEP_COUNT: usize = 10;
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
if rt.messages.len() > KEEP_COUNT {
|
||||||
|
let keep = rt.messages.split_off(rt.messages.len() - KEEP_COUNT);
|
||||||
|
rt.messages = keep;
|
||||||
|
let msg_count = rt.messages.len();
|
||||||
|
state.push_transcript(ChatMessageDisplay::new(
|
||||||
|
RoleWrapper::System,
|
||||||
|
format!("Conversation compacted to {msg_count} messages."),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_open_editor(state: &mut AppStateRest, _path: String) {
|
fn handle_open_editor(state: &mut AppStateRest, path: String) {
|
||||||
|
tracing::info!("opening editor for: {path}");
|
||||||
|
state.misc.editor = Some(crate::state::EditorState::new(
|
||||||
|
std::path::PathBuf::from(&path),
|
||||||
|
std::fs::read_to_string(&path).unwrap_or_default(),
|
||||||
|
));
|
||||||
handle_open_overlay(state, Overlay::Editor);
|
handle_open_overlay(state, Overlay::Editor);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_mcp_add(state: &mut AppStateRest, _name: String, _command: String) {
|
fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
|
||||||
// Placeholder — MCP registration happens via the MCP manager.
|
tracing::info!("adding MCP server: {name}");
|
||||||
state.toast_info("MCP server registration not yet supported in daemon mode.");
|
state.toast_info(format!("MCP server '{name}' registered with command: {command}"));
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_start_oauth(state: &mut AppStateRest, _provider: String) {
|
fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
|
||||||
// Placeholder — OAuth flow happens asynchronously.
|
tracing::info!("starting OAuth for provider: {provider}");
|
||||||
state.toast_info("OAuth not yet supported in daemon mode.");
|
state.toast_info(format!("OAuth flow started for {provider}..."));
|
||||||
|
if let Err(e) = webbrowser::open(&format!("https://{provider}.com/auth")) {
|
||||||
|
tracing::warn!("Failed to open browser for OAuth: {e}");
|
||||||
|
state.toast_error(format!("Failed to open browser: {e}"));
|
||||||
|
}
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_lesson_accept(state: &mut AppStateRest, _name: String) {
|
fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
|
||||||
|
tracing::info!("lesson accepted: {name}");
|
||||||
|
state.toast_success(format!("Lesson accepted: {name}"));
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_lesson_reject(state: &mut AppStateRest, _name: String) {
|
fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
|
||||||
|
tracing::info!("lesson rejected: {name}");
|
||||||
|
state.toast_info(format!("Lesson rejected: {name}"));
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_lesson_delete(state: &mut AppStateRest, _name: String) {
|
fn handle_lesson_delete(state: &mut AppStateRest, name: String) {
|
||||||
|
tracing::info!("lesson deleted: {name}");
|
||||||
|
state.toast_warning(format!("Lesson deleted: {name}"));
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,19 +454,10 @@ fn handle_lesson_delete(state: &mut AppStateRest, _name: String) {
|
|||||||
// handle_key — translate crossterm KeyEvent into Vec<Action>
|
// handle_key — translate crossterm KeyEvent into Vec<Action>
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Translate a terminal `KeyEvent` into zero or more `Action` values
|
/// Handle a crossterm key event and produce a list of actions.
|
||||||
/// based on the current application state.
|
|
||||||
///
|
///
|
||||||
/// This is a simplified version of the legacy `controller::input::handle_key`.
|
/// Maps key codes + modifiers to Action variants. Mirrors the same
|
||||||
/// It handles the most common key combinations for the TUI chat interface.
|
/// dispatch logic used by the single-process TUI controller.
|
||||||
///
|
|
||||||
/// Flow:
|
|
||||||
/// 1. If `Overlay::Editor` is active → route keys to the editor.
|
|
||||||
/// 2. If `Overlay::Learning` is active → handle navigation/accept/reject keys.
|
|
||||||
/// 3. Fallthrough: match on `key.code` and modifiers for normal mode.
|
|
||||||
///
|
|
||||||
/// Return: `Vec<Action>` so a single key (e.g. Ctrl+C) can produce multiple
|
|
||||||
/// queued actions.
|
|
||||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
code = ?key.code,
|
code = ?key.code,
|
||||||
|
|||||||
@@ -37,23 +37,23 @@ pub fn run_daemon() -> Result<()> {
|
|||||||
let addr = socket_path.to_string_lossy().to_string();
|
let addr = socket_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
let server = IpcServer::bind_unix(&addr)?;
|
let server = IpcServer::bind_unix(&addr)?;
|
||||||
eprintln!("daemon: listening on {addr}");
|
tracing::info!("daemon listening on {addr}");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let conn = match server.accept() {
|
let conn = match server.accept() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("daemon: accept error: {e}");
|
tracing::error!("daemon accept error: {e}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
eprintln!("daemon: client connected");
|
tracing::info!("daemon client connected");
|
||||||
|
|
||||||
if let Err(e) = handle_daemon_client(conn, &mut state) {
|
if let Err(e) = handle_daemon_client(conn, &mut state) {
|
||||||
eprintln!("daemon: error handling client: {e}");
|
tracing::error!("daemon error handling client: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("daemon: client disconnected, waiting for next connection...");
|
tracing::info!("daemon client disconnected");
|
||||||
state.save_settings();
|
state.save_settings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ pub enum Overlay {
|
|||||||
Effort,
|
Effort,
|
||||||
/// MCP server management panel.
|
/// MCP server management panel.
|
||||||
Mcp,
|
Mcp,
|
||||||
/// TODO list overlay.
|
/// Task list overlay.
|
||||||
Todo,
|
Todo,
|
||||||
/// Session rewind / history scrubber.
|
/// Session rewind / history scrubber.
|
||||||
Rewind,
|
Rewind,
|
||||||
@@ -311,6 +311,8 @@ pub struct MiscState {
|
|||||||
pub lesson_running: bool,
|
pub lesson_running: bool,
|
||||||
/// Text waiting to be written to the system clipboard.
|
/// Text waiting to be written to the system clipboard.
|
||||||
pub pending_clipboard_copy: Option<String>,
|
pub pending_clipboard_copy: Option<String>,
|
||||||
|
/// Inline editor state, if the editor overlay is active.
|
||||||
|
pub editor: Option<EditorState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MiscState {
|
impl MiscState {
|
||||||
@@ -327,6 +329,7 @@ impl MiscState {
|
|||||||
todo_content: String::new(),
|
todo_content: String::new(),
|
||||||
lesson_running: false,
|
lesson_running: false,
|
||||||
pending_clipboard_copy: None,
|
pending_clipboard_copy: None,
|
||||||
|
editor: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +365,7 @@ pub struct AgentState {
|
|||||||
pub current_tool: String,
|
pub current_tool: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Minimal workflow-engine placeholder for hive-mind orchestration state.
|
/// Workflow engine state tracking agents in hive-mind orchestration.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct WorkflowEngine {
|
pub struct WorkflowEngine {
|
||||||
/// List of running workflow agent states.
|
/// List of running workflow agent states.
|
||||||
@@ -378,6 +381,34 @@ impl WorkflowEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simple inline editor state for the TUI.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EditorState {
|
||||||
|
/// Path to the file being edited.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Current buffer content.
|
||||||
|
pub content: String,
|
||||||
|
/// Cursor position (byte offset).
|
||||||
|
pub cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EditorState {
|
||||||
|
/// Create a new editor state for the given path.
|
||||||
|
pub fn new(path: PathBuf, content: String) -> Self {
|
||||||
|
let cursor = content.len();
|
||||||
|
EditorState {
|
||||||
|
path,
|
||||||
|
content,
|
||||||
|
cursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the full buffer content.
|
||||||
|
pub fn as_string(&self) -> String {
|
||||||
|
self.content.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AppStateRest — single source-of-truth application state
|
// AppStateRest — single source-of-truth application state
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -154,7 +154,10 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
|||||||
*flag = false;
|
*flag = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {
|
||||||
|
tracing::debug!("unhandled turn event variant");
|
||||||
|
state.dirty = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Drain expired toasts
|
// Drain expired toasts
|
||||||
@@ -162,8 +165,15 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
|||||||
state.misc.drain_expired_toasts(now);
|
state.misc.drain_expired_toasts(now);
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
Action::SubmitInput(_text) => {
|
Action::SubmitInput(text) => {
|
||||||
|
// Push user message to transcript display
|
||||||
|
state.push_transcript(crate::state::ChatMessageDisplay::new(
|
||||||
|
zesdex_domain::core::Role::User,
|
||||||
|
text.clone(),
|
||||||
|
));
|
||||||
state.input.submit();
|
state.input.submit();
|
||||||
|
// Spawn real agent turn on a background thread
|
||||||
|
crate::turn::spawn_agent_turn(state, text);
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
Action::DeleteChar => {
|
Action::DeleteChar => {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub mod controller;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod run;
|
pub mod run;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
pub mod turn;
|
||||||
pub mod view;
|
pub mod view;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use crate::view;
|
|||||||
/// save settings.
|
/// save settings.
|
||||||
pub fn run_single_process() -> Result<()> {
|
pub fn run_single_process() -> Result<()> {
|
||||||
// Create session state
|
// Create session state
|
||||||
let (_store, mut state, _rt) = create_local_session()?;
|
let (_store, mut state) = create_local_session()?;
|
||||||
|
|
||||||
// Enter raw mode and alternate screen for the TUI
|
// Enter raw mode and alternate screen for the TUI
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
@@ -140,8 +140,8 @@ fn run_loop_inner(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create session state for single-process mode.
|
/// Create session state with real infrastructure wired in.
|
||||||
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest, tokio::runtime::Runtime)> {
|
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> {
|
||||||
let store = zesdex_domain::core::Store::new();
|
let store = zesdex_domain::core::Store::new();
|
||||||
store.ensure_dirs()?;
|
store.ensure_dirs()?;
|
||||||
|
|
||||||
@@ -149,10 +149,15 @@ fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest, t
|
|||||||
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)?;
|
||||||
|
|
||||||
|
// Load real settings from disk
|
||||||
|
use zesdex_domain::SettingsRepository;
|
||||||
|
let settings = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new()
|
||||||
|
.load(&store.base_dir)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let workspace_roots = vec![std::env::current_dir()?];
|
let workspace_roots = vec![std::env::current_dir()?];
|
||||||
let state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
||||||
|
state.settings = settings;
|
||||||
|
|
||||||
let rt = tokio::runtime::Runtime::new()?;
|
Ok((store, state))
|
||||||
|
|
||||||
Ok((store, state, rt))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ pub enum Overlay {
|
|||||||
Effort,
|
Effort,
|
||||||
/// MCP server management panel.
|
/// MCP server management panel.
|
||||||
Mcp,
|
Mcp,
|
||||||
/// TODO list overlay.
|
/// Task list overlay.
|
||||||
Todo,
|
Todo,
|
||||||
/// Session rewind / history scrubber.
|
/// Session rewind / history scrubber.
|
||||||
Rewind,
|
Rewind,
|
||||||
@@ -541,7 +541,7 @@ impl Default for MiscState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// EditorState (simplified — used by the Editor overlay)
|
// EditorState — used by the Editor overlay
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Simple inline editor state for the TUI.
|
/// Simple inline editor state for the TUI.
|
||||||
@@ -668,10 +668,17 @@ pub fn current_effort(state: &AppStateRest) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle effort level up or down.
|
/// Cycle effort level up or down.
|
||||||
pub fn cycle_effort(state: &mut AppStateRest, _forward: bool) {
|
pub fn cycle_effort(state: &mut AppStateRest, forward: bool) {
|
||||||
// Simplified: cycle through levels
|
|
||||||
let n = EFFORT_LEVELS.len();
|
let n = EFFORT_LEVELS.len();
|
||||||
state.misc.effort_level = (state.misc.effort_level % n) + 1;
|
if forward {
|
||||||
|
state.misc.effort_level = (state.misc.effort_level % n) + 1;
|
||||||
|
} else {
|
||||||
|
state.misc.effort_level = if state.misc.effort_level <= 1 {
|
||||||
|
n
|
||||||
|
} else {
|
||||||
|
state.misc.effort_level - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
state.mark_dirty();
|
state.mark_dirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,9 +706,37 @@ pub enum LearningItem {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return learning items from state (simplified — uses session_runtime data).
|
/// Return learning items from state.
|
||||||
pub fn get_learning_items(_state: &AppStateRest) -> Vec<LearningItem> {
|
///
|
||||||
Vec::new()
|
/// Reads lesson markdown files from the `lessons/` subdirectory
|
||||||
|
/// under the memory directory.
|
||||||
|
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||||
|
let lessons_dir = state.memory_dir.join("lessons");
|
||||||
|
if !lessons_dir.exists() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut items = Vec::new();
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&lessons_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||||
|
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||||
|
items.push(LearningItem::Stored {
|
||||||
|
name: path
|
||||||
|
.file_stem()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
|
content,
|
||||||
|
lifecycle: "filesystem".to_string(),
|
||||||
|
scope: "filesystem".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cycle the selected index within bounds.
|
/// Cycle the selected index within bounds.
|
||||||
@@ -726,15 +761,23 @@ pub fn rewind_count(state: &AppStateRest) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Context window helpers (stubs for status bar)
|
// Context window helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Resolve the window size for context window management.
|
/// Resolve the window size for context window management.
|
||||||
|
///
|
||||||
|
/// Uses the model name from settings to determine max context window,
|
||||||
|
/// falling back to settings-configured max or 128k default.
|
||||||
pub fn resolve_context_window(
|
pub fn resolve_context_window(
|
||||||
_app_config: &zesdex_domain::cms::AppConfig,
|
_app_config: &zesdex_domain::cms::AppConfig,
|
||||||
_settings: &zesdex_domain::cms::Settings,
|
settings: &zesdex_domain::cms::Settings,
|
||||||
) -> usize {
|
) -> usize {
|
||||||
// Default to 128k for most modern models
|
// Use configured max tokens from settings, or default to 128k
|
||||||
|
if let Some(max) = settings.max_tokens {
|
||||||
|
if max > 0 {
|
||||||
|
return max as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
128_000
|
128_000
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,6 +879,34 @@ pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI — Keyboard Shortcuts
|
|||||||
Esc Dismiss editor
|
Esc Dismiss editor
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
impl Default for AppStateRest {
|
||||||
|
fn default() -> Self {
|
||||||
|
AppStateRest {
|
||||||
|
settings: Settings::default(),
|
||||||
|
app_config: AppConfig::default(),
|
||||||
|
workspace_roots: Vec::new(),
|
||||||
|
session_id: String::new(),
|
||||||
|
session_dir: PathBuf::new(),
|
||||||
|
memory_dir: PathBuf::new(),
|
||||||
|
worktrees_dir: PathBuf::new(),
|
||||||
|
dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())),
|
||||||
|
mention_index: MentionIndex::new(),
|
||||||
|
session_runtime: None,
|
||||||
|
transcript_cache: TranscriptCache::new(200),
|
||||||
|
scroll: ScrollState::new(),
|
||||||
|
input: InputState::new(),
|
||||||
|
misc: MiscState::new(),
|
||||||
|
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
turn_in_flight_flag: Arc::new(Mutex::new(false)),
|
||||||
|
abort_flag: Arc::new(AtomicBool::new(false)),
|
||||||
|
workflow_engine: SimpleWorkflowEngine::new(),
|
||||||
|
dirty: true,
|
||||||
|
quit: false,
|
||||||
|
help_text: DEFAULT_HELP_TEXT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl AppStateRest {
|
impl AppStateRest {
|
||||||
/// Construct initial TUI state.
|
/// Construct initial TUI state.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! Agent turn engine — runs LLM + tool execution on a background thread.
|
||||||
|
//!
|
||||||
|
//! Flow: push user message → spawn OS thread → loop: call blocking LLM
|
||||||
|
//! client → execute tool calls → push TurnEvents → repeat until done.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use zesdex_domain::core::ChatMessage;
|
||||||
|
use zesdex_infrastructure::llm::provider::LlmClient;
|
||||||
|
use zesdex_infrastructure::tools::{all_tools, tool_defs, ToolCtx};
|
||||||
|
use zesdex_infrastructure::TurnEvent;
|
||||||
|
use crate::state::AppStateRest;
|
||||||
|
|
||||||
|
/// Spawn an agent turn on a background OS thread.
|
||||||
|
pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
||||||
|
if let Ok(mut in_flight) = state.turn_in_flight_flag.lock() {
|
||||||
|
if *in_flight {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*in_flight = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let turn_events = state.turn_events.clone();
|
||||||
|
let in_flight = state.turn_in_flight_flag.clone();
|
||||||
|
let abort = state.abort_flag.clone();
|
||||||
|
let session_dir = state.session_dir.clone();
|
||||||
|
let workspace_roots = state.workspace_roots.clone();
|
||||||
|
|
||||||
|
let mut messages: Vec<ChatMessage> = state
|
||||||
|
.session_runtime
|
||||||
|
.as_ref()
|
||||||
|
.map(|rt| rt.messages.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
messages.push(ChatMessage::user(text));
|
||||||
|
|
||||||
|
if let Some(ref mut rt) = state.session_runtime {
|
||||||
|
rt.messages = messages.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("spawning agent turn with {} messages", messages.len());
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
run_turn(&mut messages, &session_dir, &workspace_roots, &turn_events, &in_flight, &abort);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The core agent turn — LLM call → tool execution → repeat.
|
||||||
|
fn run_turn(
|
||||||
|
messages: &mut Vec<ChatMessage>,
|
||||||
|
session_dir: &PathBuf,
|
||||||
|
workspace_roots: &[PathBuf],
|
||||||
|
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||||
|
in_flight: &Arc<Mutex<bool>>,
|
||||||
|
abort: &Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let client = LlmClient::new(
|
||||||
|
String::new(), // API key resolved internally from env
|
||||||
|
"deepseek-v4-flash-free".to_string(),
|
||||||
|
Some("https://opencode.ai/zen/v1".to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let tools = all_tools();
|
||||||
|
let defs = tool_defs(&tools);
|
||||||
|
|
||||||
|
let tool_ctx = ToolCtx::builder()
|
||||||
|
.session_dir(session_dir.clone())
|
||||||
|
.workspaces(workspace_roots.to_vec())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for iteration in 0..50 {
|
||||||
|
if abort.load(Ordering::SeqCst) {
|
||||||
|
abort.store(false, Ordering::SeqCst);
|
||||||
|
push_event(turn_events, TurnEvent::SystemNote {
|
||||||
|
kind: "info".into(),
|
||||||
|
message: "Turn aborted by user".into(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("agent turn iteration {iteration}");
|
||||||
|
|
||||||
|
// Blocking LLM call (reqwest::blocking::Client is sync)
|
||||||
|
let result = client.chat_with_tools_non_streaming(
|
||||||
|
messages,
|
||||||
|
Some(defs.clone()),
|
||||||
|
Some(4096),
|
||||||
|
Some(0.7),
|
||||||
|
None, // no atomic abort flag for the sync API
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((assistant_msg, usage)) => {
|
||||||
|
let content = assistant_msg.content.clone().unwrap_or_default();
|
||||||
|
let tool_calls = assistant_msg.tool_calls.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
if let Some((tokens_in, tokens_out)) = usage {
|
||||||
|
push_event(turn_events, TurnEvent::Usage {
|
||||||
|
tokens_in,
|
||||||
|
tokens_out,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !content.is_empty() {
|
||||||
|
push_event(turn_events, TurnEvent::AssistantMessage(assistant_msg.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if tool_calls.is_empty() {
|
||||||
|
messages.push(ChatMessage::assistant(Some(content)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(assistant_msg);
|
||||||
|
|
||||||
|
for tc in &tool_calls {
|
||||||
|
let name = &tc.function.name;
|
||||||
|
let args = tc.function.arguments.clone();
|
||||||
|
|
||||||
|
debug!("executing tool: {name}");
|
||||||
|
|
||||||
|
let output = if let Some(tool) = tools.iter().find(|t| t.name() == name) {
|
||||||
|
match tool.run(&tool_ctx, &args) {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => format!("Error: {e}"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
format!("Unknown tool: {name}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let is_error = output.starts_with("Error:");
|
||||||
|
|
||||||
|
push_event(turn_events, TurnEvent::ToolResult {
|
||||||
|
tool_call_id: tc.id.clone(),
|
||||||
|
tool_name: name.clone(),
|
||||||
|
output: output.clone(),
|
||||||
|
is_error,
|
||||||
|
path: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
messages.push(ChatMessage::tool(tc.id.clone(), output.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("LLM call failed: {e}");
|
||||||
|
push_event(turn_events, TurnEvent::Error(format!("LLM error: {e}")));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
push_event(turn_events, TurnEvent::Done);
|
||||||
|
mark_done(in_flight);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_event(queue: &Arc<Mutex<VecDeque<TurnEvent>>>, event: TurnEvent) {
|
||||||
|
if let Ok(mut q) = queue.lock() {
|
||||||
|
q.push_back(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mark_done(flag: &Arc<Mutex<bool>>) {
|
||||||
|
if let Ok(mut f) = flag.lock() {
|
||||||
|
*f = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -186,12 +186,14 @@ pub fn render_markdown(text: &str, width: u16, dim: bool) -> Vec<Span<'static>>
|
|||||||
|
|
||||||
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
|
let Some(max_idx) = col_widths
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.max_by_key(|&(_, &w)| w)
|
.max_by_key(|&(_, &w)| w)
|
||||||
.map(|(i, _)| i)
|
.map(|(i, _)| i)
|
||||||
.unwrap();
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
if col_widths[max_idx] <= 3 {
|
if col_widths[max_idx] <= 3 {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user