feat(tui): implement agent turn engine for background processing and enhance input handling

This commit is contained in:
asepharyana
2026-07-20 10:55:09 +07:00
parent da2ed6da25
commit 792695b65a
31 changed files with 603 additions and 153 deletions
@@ -55,8 +55,12 @@ impl LoopbackServer {
Missing authorization code."
}
};
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
if let Err(e) = stream.write_all(response.as_bytes()) {
tracing::warn!("OAuth loopback write error: {e}");
}
if let Err(e) = stream.flush() {
tracing::warn!("OAuth loopback flush error: {e}");
}
if !state_ok {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
+9 -1
View File
@@ -3,6 +3,8 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tracing::error;
use super::job::BashJob;
/// Central registry of all running background bash jobs.
@@ -37,7 +39,13 @@ impl BashControl {
/// List all active jobs.
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
.iter()
+9 -1
View File
@@ -4,6 +4,8 @@ use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tracing::error;
/// A handle to a spawned background bash job.
pub struct BashJob {
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)
let job_clone = Arc::clone(&job);
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 {
let _ = child.wait();
}
+3 -1
View File
@@ -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));
}
}
_ => {}
_ => {
tracing::debug!("no guard pattern registered for tool: {tool_name}");
}
}
None
}
+10 -3
View File
@@ -240,7 +240,9 @@ impl LlmClient {
StreamEvent::Token(_) | StreamEvent::Reasoning(_) => {
captured_content = true;
}
_ => {}
_ => {
tracing::debug!("unhandled stream event type in wrapped closure");
}
}
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;
return Ok((turn.build_assistant_message(), usage));
}
_ => turn.apply_event(&event),
other => {
tracing::debug!("unhandled stream event type: {other:?}");
turn.apply_event(other);
}
}
}
}
+18 -6
View File
@@ -32,8 +32,8 @@ impl LspClient {
.stderr(Stdio::piped())
.spawn()?;
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let stdin = child.stdin.take().ok_or_else(|| anyhow::anyhow!("no stdin on LSP process"))?;
let stdout = BufReader::new(child.stdout.take().ok_or_else(|| anyhow::anyhow!("no stdout on LSP process"))?);
info!("LSP client spawned: {command}");
Ok(LspClient {
@@ -48,7 +48,13 @@ impl LspClient {
/// Send a JSON-RPC request and read the response.
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;
let request = serde_json::json!({
"jsonrpc": "2.0",
@@ -92,8 +98,12 @@ impl LspClient {
/// Gracefully shut down the server.
pub fn shutdown(&self) -> Result<()> {
let null = Value::Null;
let _ = self.send_request("shutdown", &null);
let _ = self.send_request("exit", &null);
if let Err(e) = self.send_request("shutdown", &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() {
let _ = inner.process.wait();
}
@@ -105,7 +115,9 @@ impl LspClient {
impl Drop for LspClient {
fn drop(&mut self) {
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();
}
}
+6 -2
View File
@@ -23,7 +23,9 @@ impl McpTransport {
pub fn stop(&mut self) -> anyhow::Result<()> {
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();
}
Ok(())
@@ -33,7 +35,9 @@ impl McpTransport {
impl Drop for McpTransport {
fn drop(&mut self) {
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();
}
}
+20 -3
View File
@@ -78,8 +78,25 @@ impl Tool for BashKill {
}
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
let _job_id = crate::tools::arg_str(args, "job_id")?;
// In production, look up and kill the job in BashControl
Ok(format!("Killed background job '{}'", _job_id))
let job_id = crate::tools::arg_str(args, "job_id")?;
info!("bash_kill called for 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 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) {
let result = client.send_request("textDocument/completion", &json!({
"textDocument": { "uri": format!("file://{}", path) },
+7 -1
View File
@@ -46,7 +46,13 @@ impl Tool for LspConnect {
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.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)?;
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 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) {
let result = client.send_request("textDocument/definition", &json!({
"textDocument": { "uri": format!("file://{}", path) },
@@ -36,7 +36,13 @@ impl Tool for LspDiagnostics {
let language = crate::tools::arg_str(args, "language")?;
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) {
let result = client.send_request("textDocument/diagnostic", &json!({
"textDocument": { "uri": format!("file://{}", path) }
@@ -30,7 +30,13 @@ impl Tool for LspDisconnect {
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<String> {
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}'"))
}
}
+7 -1
View File
@@ -46,7 +46,13 @@ impl Tool for LspHover {
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 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) {
let result = client.send_request("textDocument/hover", &json!({
"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 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) {
let result = client.send_request("textDocument/references", &json!({
"textDocument": { "uri": format!("file://{}", path) },
+21 -3
View File
@@ -3,6 +3,7 @@
use crate::tools::{Tool, ToolCtx};
use anyhow::Result;
use serde_json::{json, Value};
use tracing::info;
pub struct PlanEnter;
@@ -51,11 +52,28 @@ impl Tool for PlanReady {
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {}
"properties": {
"plan": {
"type": "string",
"description": "The final plan content"
}
},
"required": ["plan"]
})
}
fn run(&self, _ctx: &ToolCtx, _args: &Value) -> Result<String> {
Ok("Plan is ready. Starting execution.".to_string())
fn run(&self, ctx: &ToolCtx, args: &Value) -> Result<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.
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();
for cap in re.captures_iter(cmd) {
let path = cap.get(1).map(|m| m.as_str()).unwrap_or("");