use agent_client_protocol::{self as acp};
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use tokio::io::AsyncReadExt;
use tokio::sync::mpsc;
#[allow(clippy::needless_pass_by_value)]
fn io_err(e: std::io::Error) -> acp::Error {
acp::Error::internal_error().data(serde_json::Value::String(e.to_string()))
}
pub enum ClientEvent {
SessionUpdate(acp::SessionUpdate),
PermissionRequest {
request: acp::RequestPermissionRequest,
response_tx: tokio::sync::oneshot::Sender<acp::RequestPermissionResponse>,
},
TurnComplete,
TurnCancelled,
TurnError(String),
Connected {
session_id: acp::SessionId,
model_name: String,
mode: Option<crate::app::ModeState>,
},
ConnectionFailed(String),
AuthRequired { method_name: String, method_description: String },
SlashCommandError(String),
SessionReplaced {
session_id: acp::SessionId,
model_name: String,
mode: Option<crate::app::ModeState>,
},
UpdateAvailable { latest_version: String, current_version: String },
}
pub type TerminalMap = Rc<RefCell<HashMap<String, TerminalProcess>>>;
pub struct ClaudeClient {
event_tx: mpsc::UnboundedSender<ClientEvent>,
auto_approve: bool,
terminals: TerminalMap,
cwd: PathBuf,
}
pub struct TerminalProcess {
child: tokio::process::Child,
pub(crate) output_buffer: Arc<Mutex<Vec<u8>>>,
output_cursor: usize,
pub(crate) command: String,
}
fn spawn_output_reader(
mut reader: impl tokio::io::AsyncRead + Unpin + 'static,
buffer: Arc<Mutex<Vec<u8>>>,
) {
tokio::task::spawn_local(async move {
let mut chunk = [0u8; 4096];
loop {
match reader.read(&mut chunk).await {
Ok(0) => break,
Ok(n) => {
if let Ok(mut buf) = buffer.lock() {
buf.extend_from_slice(&chunk[..n]);
} else {
break;
}
}
Err(e) => {
tracing::warn!("terminal output reader error: {e}");
break;
}
}
}
});
}
impl ClaudeClient {
pub fn new(
event_tx: mpsc::UnboundedSender<ClientEvent>,
auto_approve: bool,
cwd: PathBuf,
) -> (Self, TerminalMap) {
let terminals = Rc::new(RefCell::new(HashMap::new()));
(Self { event_tx, auto_approve, terminals: Rc::clone(&terminals), cwd }, terminals)
}
pub fn with_terminals(
event_tx: mpsc::UnboundedSender<ClientEvent>,
auto_approve: bool,
cwd: PathBuf,
terminals: TerminalMap,
) -> Self {
Self { event_tx, auto_approve, terminals, cwd }
}
}
pub fn kill_all_terminals(terminals: &TerminalMap) {
let mut map = terminals.borrow_mut();
for (_, terminal) in map.iter_mut() {
let _ = terminal.child.start_kill();
}
map.clear();
}
#[async_trait::async_trait(?Send)]
impl acp::Client for ClaudeClient {
async fn request_permission(
&self,
req: acp::RequestPermissionRequest,
) -> acp::Result<acp::RequestPermissionResponse> {
if self.auto_approve {
let allow_option = req
.options
.iter()
.find(|o| {
matches!(
o.kind,
acp::PermissionOptionKind::AllowOnce
| acp::PermissionOptionKind::AllowAlways
)
})
.ok_or_else(|| {
acp::Error::internal_error()
.data(serde_json::Value::String("No allow option found".into()))
})?;
return Ok(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
allow_option.option_id.clone(),
)),
));
}
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
self.event_tx.send(ClientEvent::PermissionRequest { request: req, response_tx }).map_err(
|_| {
acp::Error::internal_error()
.data(serde_json::Value::String("Event channel closed".into()))
},
)?;
response_rx.await.map_err(|_| {
acp::Error::internal_error()
.data(serde_json::Value::String("Permission dialog cancelled".into()))
})
}
async fn session_notification(
&self,
notification: acp::SessionNotification,
) -> acp::Result<()> {
self.event_tx.send(ClientEvent::SessionUpdate(notification.update)).map_err(|_| {
acp::Error::internal_error()
.data(serde_json::Value::String("Event channel closed".into()))
})?;
Ok(())
}
async fn read_text_file(
&self,
req: acp::ReadTextFileRequest,
) -> acp::Result<acp::ReadTextFileResponse> {
let content = tokio::fs::read_to_string(&req.path).await.map_err(io_err)?;
let filtered = if req.line.is_some() || req.limit.is_some() {
let lines: Vec<&str> = content.lines().collect();
let start = req.line.map_or(0, |l| (l as usize).saturating_sub(1));
let end = req.limit.map_or(lines.len(), |l| (start + l as usize).min(lines.len()));
lines[start..end].join("\n")
} else {
content
};
Ok(acp::ReadTextFileResponse::new(filtered))
}
async fn write_text_file(
&self,
req: acp::WriteTextFileRequest,
) -> acp::Result<acp::WriteTextFileResponse> {
tokio::fs::write(&req.path, &req.content).await.map_err(io_err)?;
Ok(acp::WriteTextFileResponse::new())
}
async fn create_terminal(
&self,
req: acp::CreateTerminalRequest,
) -> acp::Result<acp::CreateTerminalResponse> {
let cwd = req.cwd.unwrap_or_else(|| self.cwd.clone());
let mut command = if cfg!(windows) {
let mut c = tokio::process::Command::new("cmd.exe");
c.arg("/C").arg(&req.command);
c
} else {
let mut c = tokio::process::Command::new("sh");
c.arg("-c").arg(&req.command);
c
};
command.args(&req.args);
let mut child = command
.current_dir(&cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.envs(req.env.iter().map(|e| (&e.name, &e.value)))
.env("FORCE_COLOR", "1")
.env("CLICOLOR_FORCE", "1")
.env("CARGO_TERM_COLOR", "always")
.spawn()
.map_err(io_err)?;
let output_buffer = Arc::new(Mutex::new(Vec::new()));
if let Some(stdout) = child.stdout.take() {
spawn_output_reader(stdout, Arc::clone(&output_buffer));
}
if let Some(stderr) = child.stderr.take() {
spawn_output_reader(stderr, Arc::clone(&output_buffer));
}
let terminal_id = uuid::Uuid::new_v4().to_string();
self.terminals.borrow_mut().insert(
terminal_id.clone(),
TerminalProcess {
child,
output_buffer,
output_cursor: 0,
command: req.command.clone(),
},
);
Ok(acp::CreateTerminalResponse::new(terminal_id))
}
async fn terminal_output(
&self,
req: acp::TerminalOutputRequest,
) -> acp::Result<acp::TerminalOutputResponse> {
let tid = req.terminal_id.to_string();
let mut terminals = self.terminals.borrow_mut();
let terminal = terminals.get_mut(tid.as_str()).ok_or_else(|| {
acp::Error::internal_error()
.data(serde_json::Value::String(format!("Terminal not found: {tid}")))
})?;
let output = {
if let Ok(buf) = terminal.output_buffer.lock() {
let new_data = &buf[terminal.output_cursor..];
let data = String::from_utf8_lossy(new_data).to_string();
terminal.output_cursor = buf.len();
data
} else {
String::new()
}
};
let exit_status = match terminal.child.try_wait().map_err(io_err)? {
Some(status) => {
let mut es = acp::TerminalExitStatus::new();
if let Some(code) = status.code() {
es = es.exit_code(code.unsigned_abs());
}
Some(es)
}
None => None,
};
let mut response = acp::TerminalOutputResponse::new(output, false);
if let Some(es) = exit_status {
response = response.exit_status(es);
}
Ok(response)
}
async fn kill_terminal_command(
&self,
req: acp::KillTerminalCommandRequest,
) -> acp::Result<acp::KillTerminalCommandResponse> {
let tid = req.terminal_id.to_string();
let mut terminals = self.terminals.borrow_mut();
if let Some(terminal) = terminals.get_mut(tid.as_str()) {
terminal.child.start_kill().map_err(io_err)?;
}
Ok(acp::KillTerminalCommandResponse::new())
}
async fn wait_for_terminal_exit(
&self,
req: acp::WaitForTerminalExitRequest,
) -> acp::Result<acp::WaitForTerminalExitResponse> {
let tid = req.terminal_id.to_string();
loop {
{
let mut terminals = self.terminals.borrow_mut();
let terminal = terminals.get_mut(tid.as_str()).ok_or_else(|| {
acp::Error::internal_error()
.data(serde_json::Value::String("Terminal not found".into()))
})?;
if let Some(status) = terminal.child.try_wait().map_err(io_err)? {
let mut exit_status = acp::TerminalExitStatus::new();
if let Some(code) = status.code() {
exit_status = exit_status.exit_code(code.unsigned_abs());
}
return Ok(acp::WaitForTerminalExitResponse::new(exit_status));
}
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
async fn release_terminal(
&self,
req: acp::ReleaseTerminalRequest,
) -> acp::Result<acp::ReleaseTerminalResponse> {
let tid = req.terminal_id.to_string();
self.terminals.borrow_mut().remove(tid.as_str());
Ok(acp::ReleaseTerminalResponse::new())
}
}