| 1 | //! A terminal UI for any ACP agent. It is a regular ACP client: it launches the agent (usually |
| 2 | //! the same binary with `--acp`) as a subprocess and renders one session with ratatui, so it |
| 3 | //! exercises exactly the protocol path an editor would. It serves `fs/read_text_file` and |
| 4 | //! `fs/write_text_file` from the local disk and answers permission requests with `y` (allow), |
| 5 | //! `a` (always), `n` (reject) or `r` (always reject). |
| 6 | |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | use std::time::Duration; |
| 9 | |
| 10 | use agent_client_protocol::schema::ProtocolVersion; |
| 11 | use agent_client_protocol::schema::v1::{ |
| 12 | CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities, |
| 13 | InitializeRequest, NewSessionRequest, PermissionOptionKind, PromptRequest, ReadTextFileRequest, |
| 14 | ReadTextFileResponse, RequestPermissionOutcome, RequestPermissionRequest, |
| 15 | RequestPermissionResponse, SelectedPermissionOutcome, SessionId, SessionNotification, |
| 16 | SessionUpdate, StopReason, TextContent, ToolCallContent, ToolCallId, ToolCallStatus, |
| 17 | WriteTextFileRequest, WriteTextFileResponse, |
| 18 | }; |
| 19 | use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent, ConnectionTo}; |
| 20 | use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; |
| 21 | use futures::StreamExt; |
| 22 | use ratatui::layout::{Constraint, Layout, Rect}; |
| 23 | use ratatui::style::{Color, Modifier, Style, Stylize}; |
| 24 | use ratatui::text::{Line, Span}; |
| 25 | use ratatui::widgets::{Block, BorderType, Clear, Paragraph, Wrap}; |
| 26 | use ratatui::{DefaultTerminal, Frame}; |
| 27 | use tokio::sync::{mpsc, oneshot}; |
| 28 | |
| 29 | const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; |
| 30 | const MAX_TOOL_OUTPUT_LINES: usize = 8; |
| 31 | |
| 32 | enum AppEvent { |
| 33 | Update(SessionUpdate), |
| 34 | Permission( |
| 35 | RequestPermissionRequest, |
| 36 | oneshot::Sender<RequestPermissionOutcome>, |
| 37 | ), |
| 38 | TurnDone(Result<StopReason, String>), |
| 39 | } |
| 40 | |
| 41 | /// What to launch and how to label it. |
| 42 | #[derive(Debug, Clone)] |
| 43 | pub struct TuiConfig { |
| 44 | /// Shown in the input box title and placeholder, e.g. `splitfire-agent`. |
| 45 | pub name: String, |
| 46 | /// The agent binary. |
| 47 | pub agent: PathBuf, |
| 48 | /// Arguments for the agent, e.g. `["--acp"]`. |
| 49 | pub args: Vec<String>, |
| 50 | /// Extra environment for the agent, e.g. `("SPLITFIRE_SURFACE", "tui")`. |
| 51 | pub env: Vec<(String, String)>, |
| 52 | /// Shown in the status bar, e.g. `onde-kkk`. |
| 53 | pub model_label: String, |
| 54 | /// Additional workspace roots sent as `additionalDirectories`. |
| 55 | pub extra_roots: Vec<PathBuf>, |
| 56 | } |
| 57 | |
| 58 | impl TuiConfig { |
| 59 | /// Launch this same executable with `--acp`, and `--yolo` when asked. |
| 60 | pub fn current_exe(name: impl Into<String>, yolo: bool) -> anyhow::Result<Self> { |
| 61 | let mut args = vec!["--acp".to_string()]; |
| 62 | if yolo { |
| 63 | args.push("--yolo".to_string()); |
| 64 | } |
| 65 | Ok(Self { |
| 66 | name: name.into(), |
| 67 | agent: std::env::current_exe()?, |
| 68 | args, |
| 69 | env: Vec::new(), |
| 70 | model_label: String::new(), |
| 71 | extra_roots: Vec::new(), |
| 72 | }) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// Run the TUI until the user quits. The working directory is the session's `cwd`. |
| 77 | pub async fn run(cfg: TuiConfig) -> anyhow::Result<()> { |
| 78 | let mut config = AcpAgentConfig::new(&cfg.agent); |
| 79 | for arg in &cfg.args { |
| 80 | config = config.arg(arg); |
| 81 | } |
| 82 | for (k, v) in &cfg.env { |
| 83 | config = config.env(k, v); |
| 84 | } |
| 85 | let cwd = std::env::current_dir()?; |
| 86 | let model = cfg.model_label.clone(); |
| 87 | let name = cfg.name.clone(); |
| 88 | let extra_roots = cfg.extra_roots.clone(); |
| 89 | let (tx, rx) = mpsc::unbounded_channel(); |
| 90 | |
| 91 | agent_client_protocol::Client |
| 92 | .builder() |
| 93 | .on_receive_notification( |
| 94 | { |
| 95 | let tx = tx.clone(); |
| 96 | async move |n: SessionNotification, _cx| { |
| 97 | let _ = tx.send(AppEvent::Update(n.update)); |
| 98 | Ok(()) |
| 99 | } |
| 100 | }, |
| 101 | agent_client_protocol::on_receive_notification!(), |
| 102 | ) |
| 103 | .on_receive_request( |
| 104 | { |
| 105 | let tx = tx.clone(); |
| 106 | async move |req: RequestPermissionRequest, responder, cx| { |
| 107 | // Wait for the user's answer off the dispatch loop so updates keep flowing. |
| 108 | let (answer_tx, answer_rx) = oneshot::channel(); |
| 109 | let _ = tx.send(AppEvent::Permission(req, answer_tx)); |
| 110 | cx.spawn(async move { |
| 111 | let outcome = answer_rx |
| 112 | .await |
| 113 | .unwrap_or(RequestPermissionOutcome::Cancelled); |
| 114 | responder.respond(RequestPermissionResponse::new(outcome)) |
| 115 | }) |
| 116 | } |
| 117 | }, |
| 118 | agent_client_protocol::on_receive_request!(), |
| 119 | ) |
| 120 | // ACP v1 fs/read_text_file: serve file contents from the session cwd, |
| 121 | // honoring the optional 1-based `line` and `limit` params. |
| 122 | .on_receive_request( |
| 123 | async move |req: ReadTextFileRequest, responder, _cx| match tokio::task::spawn_blocking( |
| 124 | { |
| 125 | let path = req.path.clone(); |
| 126 | move || read_text_file_blocking(&path, req.line, req.limit) |
| 127 | }, |
| 128 | ) |
| 129 | .await |
| 130 | { |
| 131 | Ok(Ok(r)) => responder.respond(r), |
| 132 | Ok(Err(e)) => responder.respond_with_error(e), |
| 133 | Err(e) => responder.respond_with_internal_error(e.to_string()), |
| 134 | }, |
| 135 | agent_client_protocol::on_receive_request!(), |
| 136 | ) |
| 137 | // ACP v1 fs/write_text_file: the client MUST create the file (and parent |
| 138 | // directories) if it doesn't exist. |
| 139 | .on_receive_request( |
| 140 | async move |req: WriteTextFileRequest, responder, _cx| { |
| 141 | match tokio::task::spawn_blocking({ |
| 142 | let path = req.path.clone(); |
| 143 | let content = req.content.clone(); |
| 144 | move || write_text_file_blocking(&path, &content) |
| 145 | }) |
| 146 | .await |
| 147 | { |
| 148 | Ok(Ok(r)) => responder.respond(r), |
| 149 | Ok(Err(e)) => responder.respond_with_error(e), |
| 150 | Err(e) => responder.respond_with_internal_error(e.to_string()), |
| 151 | } |
| 152 | }, |
| 153 | agent_client_protocol::on_receive_request!(), |
| 154 | ) |
| 155 | .connect_with( |
| 156 | AcpAgent::new(config), |
| 157 | |conn: ConnectionTo<Agent>| async move { |
| 158 | conn.send_request( |
| 159 | InitializeRequest::new(ProtocolVersion::V1).client_capabilities( |
| 160 | ClientCapabilities::new().fs(FileSystemCapabilities::new() |
| 161 | .read_text_file(true) |
| 162 | .write_text_file(true)), |
| 163 | ), |
| 164 | ) |
| 165 | .block_task() |
| 166 | .await?; |
| 167 | let session = conn |
| 168 | .send_request( |
| 169 | NewSessionRequest::new(cwd.clone()) |
| 170 | .additional_directories(extra_roots.clone()), |
| 171 | ) |
| 172 | .block_task() |
| 173 | .await?; |
| 174 | let mut app = App::new(conn, session.session_id, tx, model, name, cwd, extra_roots); |
| 175 | let mut terminal = ratatui::init(); |
| 176 | let result = app.run(&mut terminal, rx).await; |
| 177 | ratatui::restore(); |
| 178 | result.map_err(|e| { |
| 179 | agent_client_protocol::Error::internal_error().data(format!("{e:#}")) |
| 180 | }) |
| 181 | }, |
| 182 | ) |
| 183 | .await?; |
| 184 | Ok(()) |
| 185 | } |
| 186 | |
| 187 | /// `fs/read_text_file` handler body: reads `path`, applying the spec's optional |
| 188 | /// 1-based `line` and `limit` params. |
| 189 | fn read_text_file_blocking( |
| 190 | path: &Path, |
| 191 | line: Option<u32>, |
| 192 | limit: Option<u32>, |
| 193 | ) -> agent_client_protocol::Result<ReadTextFileResponse> { |
| 194 | let content = std::fs::read_to_string(path).map_err(|e| { |
| 195 | if e.kind() == std::io::ErrorKind::NotFound { |
| 196 | agent_client_protocol::Error::resource_not_found(Some(path.display().to_string())) |
| 197 | } else { |
| 198 | agent_client_protocol::Error::internal_error().data(e.to_string()) |
| 199 | } |
| 200 | })?; |
| 201 | let skip = line.map_or(0, |l| l.saturating_sub(1) as usize); |
| 202 | let take = limit.map_or(usize::MAX, |l| l as usize); |
| 203 | let content = content |
| 204 | .lines() |
| 205 | .skip(skip) |
| 206 | .take(take) |
| 207 | .collect::<Vec<_>>() |
| 208 | .join("\n"); |
| 209 | Ok(ReadTextFileResponse::new(content)) |
| 210 | } |
| 211 | |
| 212 | /// `fs/write_text_file` handler body: the client MUST create the file if it |
| 213 | /// doesn't exist (spec: https://agentclientprotocol.com/protocol/v1/file-system). |
| 214 | fn write_text_file_blocking( |
| 215 | path: &Path, |
| 216 | content: &str, |
| 217 | ) -> agent_client_protocol::Result<WriteTextFileResponse> { |
| 218 | let write = || { |
| 219 | if let Some(parent) = path.parent() { |
| 220 | std::fs::create_dir_all(parent)?; |
| 221 | } |
| 222 | std::fs::write(path, content) |
| 223 | }; |
| 224 | write().map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?; |
| 225 | Ok(WriteTextFileResponse::new()) |
| 226 | } |
| 227 | |
| 228 | enum Entry { |
| 229 | User(String), |
| 230 | Agent(String), |
| 231 | Thought(String), |
| 232 | Tool { |
| 233 | id: ToolCallId, |
| 234 | title: String, |
| 235 | status: ToolCallStatus, |
| 236 | output: String, |
| 237 | }, |
| 238 | Info(String), |
| 239 | Error(String), |
| 240 | } |
| 241 | |
| 242 | struct App { |
| 243 | conn: ConnectionTo<Agent>, |
| 244 | session_id: SessionId, |
| 245 | tx: mpsc::UnboundedSender<AppEvent>, |
| 246 | model: String, |
| 247 | /// The agent's name, shown in the input box. |
| 248 | name: String, |
| 249 | /// Workspace roots, primary working directory first, then any additional roots. |
| 250 | roots: Vec<PathBuf>, |
| 251 | entries: Vec<Entry>, |
| 252 | input: Vec<char>, |
| 253 | cursor: usize, |
| 254 | busy: bool, |
| 255 | permission: Option<( |
| 256 | RequestPermissionRequest, |
| 257 | oneshot::Sender<RequestPermissionOutcome>, |
| 258 | )>, |
| 259 | /// Lines scrolled up from the bottom of the transcript; 0 follows new output. |
| 260 | scroll_up: usize, |
| 261 | tick: usize, |
| 262 | quit: bool, |
| 263 | } |
| 264 | |
| 265 | impl App { |
| 266 | fn new( |
| 267 | conn: ConnectionTo<Agent>, |
| 268 | session_id: SessionId, |
| 269 | tx: mpsc::UnboundedSender<AppEvent>, |
| 270 | model: String, |
| 271 | name: String, |
| 272 | cwd: PathBuf, |
| 273 | extra_roots: Vec<PathBuf>, |
| 274 | ) -> Self { |
| 275 | let roots = std::iter::once(cwd.clone()) |
| 276 | .chain(extra_roots.into_iter().filter(|r| *r != cwd)) |
| 277 | .collect(); |
| 278 | Self { |
| 279 | conn, |
| 280 | session_id, |
| 281 | tx, |
| 282 | model, |
| 283 | name, |
| 284 | roots, |
| 285 | entries: vec![Entry::Info( |
| 286 | "Ask for a change, or type /quit to exit.".into(), |
| 287 | )], |
| 288 | input: Vec::new(), |
| 289 | cursor: 0, |
| 290 | busy: false, |
| 291 | permission: None, |
| 292 | scroll_up: 0, |
| 293 | tick: 0, |
| 294 | quit: false, |
| 295 | } |
| 296 | } |
| 297 | |
| 298 | async fn run( |
| 299 | &mut self, |
| 300 | terminal: &mut DefaultTerminal, |
| 301 | mut rx: mpsc::UnboundedReceiver<AppEvent>, |
| 302 | ) -> anyhow::Result<()> { |
| 303 | let mut keys = EventStream::new(); |
| 304 | let mut ticker = tokio::time::interval(Duration::from_millis(80)); |
| 305 | while !self.quit { |
| 306 | terminal.draw(|f| self.draw(f))?; |
| 307 | tokio::select! { |
| 308 | ev = keys.next() => match ev { |
| 309 | Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => self.on_key(key), |
| 310 | Some(Err(e)) => return Err(e.into()), |
| 311 | None => break, |
| 312 | _ => {} |
| 313 | }, |
| 314 | Some(ev) = rx.recv() => self.on_event(ev), |
| 315 | _ = ticker.tick(), if self.busy => self.tick += 1, |
| 316 | } |
| 317 | } |
| 318 | Ok(()) |
| 319 | } |
| 320 | |
| 321 | fn on_event(&mut self, ev: AppEvent) { |
| 322 | match ev { |
| 323 | AppEvent::Update(update) => self.on_update(update), |
| 324 | AppEvent::Permission(req, answer) => self.permission = Some((req, answer)), |
| 325 | AppEvent::TurnDone(result) => { |
| 326 | self.busy = false; |
| 327 | match result { |
| 328 | Ok(StopReason::EndTurn) => {} |
| 329 | Ok(StopReason::Cancelled) => { |
| 330 | self.entries.push(Entry::Info("Cancelled.".into())) |
| 331 | } |
| 332 | Ok(other) => self |
| 333 | .entries |
| 334 | .push(Entry::Info(format!("Stopped: {other:?}"))), |
| 335 | Err(e) => self.entries.push(Entry::Error(e)), |
| 336 | } |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | fn on_update(&mut self, update: SessionUpdate) { |
| 342 | match update { |
| 343 | SessionUpdate::AgentMessageChunk(chunk) => match self.entries.last_mut() { |
| 344 | Some(Entry::Agent(text)) => text.push_str(&block_text(&chunk.content)), |
| 345 | _ => self.entries.push(Entry::Agent(block_text(&chunk.content))), |
| 346 | }, |
| 347 | SessionUpdate::AgentThoughtChunk(chunk) => match self.entries.last_mut() { |
| 348 | Some(Entry::Thought(text)) => text.push_str(&block_text(&chunk.content)), |
| 349 | _ => self |
| 350 | .entries |
| 351 | .push(Entry::Thought(block_text(&chunk.content))), |
| 352 | }, |
| 353 | SessionUpdate::ToolCall(call) => self.entries.push(Entry::Tool { |
| 354 | id: call.tool_call_id, |
| 355 | title: call.title, |
| 356 | status: call.status, |
| 357 | output: summarize(&call.content), |
| 358 | }), |
| 359 | SessionUpdate::ToolCallUpdate(update) => { |
| 360 | let entry = |
| 361 | self.entries.iter_mut().rev().find( |
| 362 | |e| matches!(e, Entry::Tool { id, .. } if *id == update.tool_call_id), |
| 363 | ); |
| 364 | if let Some(Entry::Tool { |
| 365 | title, |
| 366 | status, |
| 367 | output, |
| 368 | .. |
| 369 | }) = entry |
| 370 | { |
| 371 | let fields = update.fields; |
| 372 | if let Some(s) = fields.status { |
| 373 | *status = s; |
| 374 | } |
| 375 | if let Some(t) = fields.title { |
| 376 | *title = t; |
| 377 | } |
| 378 | if let Some(c) = fields.content { |
| 379 | *output = summarize(&c); |
| 380 | } |
| 381 | } |
| 382 | } |
| 383 | _ => {} |
| 384 | } |
| 385 | } |
| 386 | |
| 387 | fn on_key(&mut self, key: KeyEvent) { |
| 388 | let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); |
| 389 | if self.permission.is_some() { |
| 390 | match key.code { |
| 391 | KeyCode::Char('y') => self.answer_permission(Some(PermissionOptionKind::AllowOnce)), |
| 392 | KeyCode::Char('a') => { |
| 393 | self.answer_permission(Some(PermissionOptionKind::AllowAlways)) |
| 394 | } |
| 395 | KeyCode::Char('r') => { |
| 396 | self.answer_permission(Some(PermissionOptionKind::RejectAlways)) |
| 397 | } |
| 398 | KeyCode::Char('n') | KeyCode::Esc => { |
| 399 | self.answer_permission(Some(PermissionOptionKind::RejectOnce)) |
| 400 | } |
| 401 | KeyCode::Char('c') if ctrl => self.cancel_turn(), |
| 402 | _ => {} |
| 403 | } |
| 404 | return; |
| 405 | } |
| 406 | match key.code { |
| 407 | KeyCode::Char('c') if ctrl => { |
| 408 | if self.busy { |
| 409 | self.cancel_turn(); |
| 410 | } else if !self.input.is_empty() { |
| 411 | self.input.clear(); |
| 412 | self.cursor = 0; |
| 413 | } else { |
| 414 | self.quit = true; |
| 415 | } |
| 416 | } |
| 417 | KeyCode::Char('d') if ctrl && self.input.is_empty() => self.quit = true, |
| 418 | KeyCode::Char('a') if ctrl => self.cursor = 0, |
| 419 | KeyCode::Char('e') if ctrl => self.cursor = self.input.len(), |
| 420 | KeyCode::Char('u') if ctrl => { |
| 421 | self.input.drain(..self.cursor); |
| 422 | self.cursor = 0; |
| 423 | } |
| 424 | KeyCode::Esc if self.busy => self.cancel_turn(), |
| 425 | KeyCode::Enter if !self.busy => self.submit(), |
| 426 | KeyCode::Char(c) if !ctrl => { |
| 427 | self.input.insert(self.cursor, c); |
| 428 | self.cursor += 1; |
| 429 | } |
| 430 | KeyCode::Backspace if self.cursor > 0 => { |
| 431 | self.cursor -= 1; |
| 432 | self.input.remove(self.cursor); |
| 433 | } |
| 434 | KeyCode::Delete if self.cursor < self.input.len() => { |
| 435 | self.input.remove(self.cursor); |
| 436 | } |
| 437 | KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), |
| 438 | KeyCode::Right => self.cursor = (self.cursor + 1).min(self.input.len()), |
| 439 | KeyCode::Home => self.cursor = 0, |
| 440 | KeyCode::End => self.cursor = self.input.len(), |
| 441 | KeyCode::PageUp => self.scroll_up += 10, |
| 442 | KeyCode::PageDown => self.scroll_up = self.scroll_up.saturating_sub(10), |
| 443 | KeyCode::Up => self.scroll_up += 1, |
| 444 | KeyCode::Down => self.scroll_up = self.scroll_up.saturating_sub(1), |
| 445 | _ => {} |
| 446 | } |
| 447 | } |
| 448 | |
| 449 | fn submit(&mut self) { |
| 450 | let text: String = self.input.iter().collect::<String>().trim().to_string(); |
| 451 | if text.is_empty() { |
| 452 | return; |
| 453 | } |
| 454 | self.input.clear(); |
| 455 | self.cursor = 0; |
| 456 | if text == "/quit" || text == "/exit" { |
| 457 | self.quit = true; |
| 458 | return; |
| 459 | } |
| 460 | self.entries.push(Entry::User(text.clone())); |
| 461 | self.busy = true; |
| 462 | self.scroll_up = 0; |
| 463 | |
| 464 | let conn = self.conn.clone(); |
| 465 | let request = PromptRequest::new( |
| 466 | self.session_id.clone(), |
| 467 | vec![ContentBlock::Text(TextContent::new(text))], |
| 468 | ); |
| 469 | let tx = self.tx.clone(); |
| 470 | tokio::spawn(async move { |
| 471 | let result = conn.send_request(request).block_task().await; |
| 472 | let _ = tx.send(AppEvent::TurnDone( |
| 473 | result.map(|r| r.stop_reason).map_err(|e| format!("{e}")), |
| 474 | )); |
| 475 | }); |
| 476 | } |
| 477 | |
| 478 | fn cancel_turn(&mut self) { |
| 479 | if let Some((_, answer)) = self.permission.take() { |
| 480 | let _ = answer.send(RequestPermissionOutcome::Cancelled); |
| 481 | } |
| 482 | if let Err(e) = self |
| 483 | .conn |
| 484 | .send_notification(CancelNotification::new(self.session_id.clone())) |
| 485 | { |
| 486 | self.entries |
| 487 | .push(Entry::Error(format!("cancel failed: {e}"))); |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | fn answer_permission(&mut self, kind: Option<PermissionOptionKind>) { |
| 492 | let Some((req, answer)) = self.permission.take() else { |
| 493 | return; |
| 494 | }; |
| 495 | let outcome = req |
| 496 | .options |
| 497 | .iter() |
| 498 | .find(|o| Some(o.kind) == kind) |
| 499 | .map(|o| { |
| 500 | RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new( |
| 501 | o.option_id.clone(), |
| 502 | )) |
| 503 | }) |
| 504 | .unwrap_or(RequestPermissionOutcome::Cancelled); |
| 505 | let _ = answer.send(outcome); |
| 506 | } |
| 507 | |
| 508 | /// Show paths under a workspace root relative to it; secondary roots get a `name/` prefix. |
| 509 | fn short(&self, text: &str) -> String { |
| 510 | let mut out = text.to_string(); |
| 511 | for (i, root) in self.roots.iter().enumerate() { |
| 512 | let mut prefix = root.display().to_string(); |
| 513 | prefix.push('/'); |
| 514 | let replacement = if i == 0 { |
| 515 | String::new() |
| 516 | } else { |
| 517 | root_name(root) |
| 518 | }; |
| 519 | out = out.replace(&prefix, &replacement); |
| 520 | } |
| 521 | out |
| 522 | } |
| 523 | |
| 524 | fn draw(&self, f: &mut Frame) { |
| 525 | let [body, input, status] = Layout::vertical([ |
| 526 | Constraint::Min(1), |
| 527 | Constraint::Length(3), |
| 528 | Constraint::Length(1), |
| 529 | ]) |
| 530 | .areas(f.area()); |
| 531 | self.draw_transcript(f, body); |
| 532 | self.draw_input(f, input); |
| 533 | self.draw_status(f, status); |
| 534 | if let Some((req, _)) = &self.permission { |
| 535 | let title = req.tool_call.fields.title.clone().or_else(|| { |
| 536 | self.entries.iter().rev().find_map(|e| match e { |
| 537 | Entry::Tool { id, title, .. } if *id == req.tool_call.tool_call_id => { |
| 538 | Some(title.clone()) |
| 539 | } |
| 540 | _ => None, |
| 541 | }) |
| 542 | }); |
| 543 | draw_permission(f, &self.short(&title.unwrap_or_else(|| "Run tool".into()))); |
| 544 | } |
| 545 | } |
| 546 | |
| 547 | fn draw_transcript(&self, f: &mut Frame, area: Rect) { |
| 548 | let mut lines: Vec<Line> = Vec::new(); |
| 549 | for entry in &self.entries { |
| 550 | match entry { |
| 551 | Entry::User(text) => { |
| 552 | for (i, l) in text.lines().enumerate() { |
| 553 | let prefix = if i == 0 { "› " } else { " " }; |
| 554 | lines.push(Line::from(vec![prefix.cyan().bold(), l.to_string().bold()])); |
| 555 | } |
| 556 | } |
| 557 | Entry::Agent(text) => { |
| 558 | lines.extend(text.trim().lines().map(|l| Line::from(l.to_string()))); |
| 559 | } |
| 560 | Entry::Thought(text) => { |
| 561 | let style = Style::new() |
| 562 | .fg(Color::DarkGray) |
| 563 | .add_modifier(Modifier::ITALIC); |
| 564 | lines.extend( |
| 565 | text.trim() |
| 566 | .lines() |
| 567 | .map(|l| Line::styled(format!(" {l}"), style)), |
| 568 | ); |
| 569 | } |
| 570 | Entry::Tool { |
| 571 | title, |
| 572 | status, |
| 573 | output, |
| 574 | .. |
| 575 | } => { |
| 576 | let icon = match status { |
| 577 | ToolCallStatus::Completed => "✓".green(), |
| 578 | ToolCallStatus::Failed => "✗".red(), |
| 579 | _ => SPINNER[self.tick % SPINNER.len()].yellow(), |
| 580 | }; |
| 581 | lines.push(Line::from(vec![icon, " ".into(), self.short(title).bold()])); |
| 582 | let output = self.short(output); |
| 583 | let out: Vec<&str> = output |
| 584 | .lines() |
| 585 | .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("```")) |
| 586 | .collect(); |
| 587 | for l in out.iter().take(MAX_TOOL_OUTPUT_LINES) { |
| 588 | lines.push(Line::from(vec![ |
| 589 | " │ ".dark_gray(), |
| 590 | l.to_string().dark_gray(), |
| 591 | ])); |
| 592 | } |
| 593 | if out.len() > MAX_TOOL_OUTPUT_LINES { |
| 594 | let more = out.len() - MAX_TOOL_OUTPUT_LINES; |
| 595 | lines.push(Line::from(format!(" │ … {more} more lines").dark_gray())); |
| 596 | } |
| 597 | } |
| 598 | Entry::Info(text) => lines.push(Line::from(text.clone().dark_gray())), |
| 599 | Entry::Error(text) => lines.push(Line::from(format!("error: {text}").red())), |
| 600 | } |
| 601 | lines.push(Line::default()); |
| 602 | } |
| 603 | |
| 604 | let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); |
| 605 | let total = paragraph.line_count(area.width); |
| 606 | let max_offset = total.saturating_sub(area.height as usize); |
| 607 | let offset = max_offset.saturating_sub(self.scroll_up); |
| 608 | f.render_widget( |
| 609 | paragraph.scroll((offset.min(u16::MAX as usize) as u16, 0)), |
| 610 | area, |
| 611 | ); |
| 612 | } |
| 613 | |
| 614 | fn draw_input(&self, f: &mut Frame, area: Rect) { |
| 615 | let border = if self.busy { |
| 616 | Color::DarkGray |
| 617 | } else { |
| 618 | Color::Cyan |
| 619 | }; |
| 620 | let block = Block::bordered() |
| 621 | .border_type(BorderType::Rounded) |
| 622 | .border_style(Style::new().fg(border)) |
| 623 | .title(format!(" {} ", self.name)); |
| 624 | let inner = block.inner(area); |
| 625 | // Scroll horizontally so the cursor stays visible. |
| 626 | let width = inner.width.saturating_sub(1) as usize; |
| 627 | let start = self.cursor.saturating_sub(width); |
| 628 | let visible: String = self.input[start..].iter().take(width + 1).collect(); |
| 629 | let text = if self.input.is_empty() && !self.busy { |
| 630 | Line::from(format!("Ask {} to do something…", self.name).dark_gray()) |
| 631 | } else { |
| 632 | Line::from(visible) |
| 633 | }; |
| 634 | f.render_widget(Paragraph::new(text).block(block), area); |
| 635 | if self.permission.is_none() { |
| 636 | f.set_cursor_position((inner.x + (self.cursor - start) as u16, inner.y)); |
| 637 | } |
| 638 | } |
| 639 | |
| 640 | fn draw_status(&self, f: &mut Frame, area: Rect) { |
| 641 | let state = if self.permission.is_some() { |
| 642 | "waiting for approval".yellow() |
| 643 | } else if self.busy { |
| 644 | format!("{} working", SPINNER[self.tick % SPINNER.len()]).yellow() |
| 645 | } else { |
| 646 | "ready".green() |
| 647 | }; |
| 648 | let hints = if self.busy { |
| 649 | "Esc cancel" |
| 650 | } else { |
| 651 | "Enter send · Ctrl-C quit" |
| 652 | }; |
| 653 | let roots = self |
| 654 | .roots |
| 655 | .iter() |
| 656 | .map(|r| r.display().to_string()) |
| 657 | .collect::<Vec<_>>() |
| 658 | .join(", "); |
| 659 | let line = Line::from(vec![ |
| 660 | " ".into(), |
| 661 | state, |
| 662 | " · ".dark_gray(), |
| 663 | self.model.clone().cyan(), |
| 664 | " · ".dark_gray(), |
| 665 | roots.dark_gray(), |
| 666 | " · ".dark_gray(), |
| 667 | hints.dark_gray(), |
| 668 | " · ↑↓/PgUp/PgDn scroll".dark_gray(), |
| 669 | ]); |
| 670 | f.render_widget(Paragraph::new(line), area); |
| 671 | } |
| 672 | } |
| 673 | |
| 674 | fn draw_permission(f: &mut Frame, title: &str) { |
| 675 | let area = f.area(); |
| 676 | let width = (title.chars().count() as u16 + 6).clamp(44, area.width.saturating_sub(4).max(1)); |
| 677 | let popup = Rect { |
| 678 | x: area.x + area.width.saturating_sub(width) / 2, |
| 679 | y: area.y + area.height.saturating_sub(7) / 2, |
| 680 | width: width.min(area.width), |
| 681 | height: 7.min(area.height), |
| 682 | }; |
| 683 | let block = Block::bordered() |
| 684 | .border_type(BorderType::Rounded) |
| 685 | .border_style(Style::new().fg(Color::Yellow)) |
| 686 | .title(" Allow this? "); |
| 687 | let text = vec![ |
| 688 | Line::from(title.to_string().bold()), |
| 689 | Line::default(), |
| 690 | Line::from(vec![ |
| 691 | Span::from("[y]").green().bold(), |
| 692 | " allow ".into(), |
| 693 | Span::from("[a]").green().bold(), |
| 694 | " always ".into(), |
| 695 | Span::from("[n]").red().bold(), |
| 696 | " reject ".into(), |
| 697 | Span::from("[r]").red().bold(), |
| 698 | " never".into(), |
| 699 | ]), |
| 700 | ]; |
| 701 | f.render_widget(Clear, popup); |
| 702 | f.render_widget( |
| 703 | Paragraph::new(text).block(block).wrap(Wrap { trim: false }), |
| 704 | popup, |
| 705 | ); |
| 706 | } |
| 707 | |
| 708 | /// Display name for a workspace root: its directory name with a trailing slash. |
| 709 | fn root_name(root: &Path) -> String { |
| 710 | match root.file_name() { |
| 711 | Some(name) => format!("{}/", name.to_string_lossy()), |
| 712 | None => root.display().to_string(), |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | fn block_text(block: &ContentBlock) -> String { |
| 717 | match block { |
| 718 | ContentBlock::Text(t) => t.text.clone(), |
| 719 | _ => String::new(), |
| 720 | } |
| 721 | } |
| 722 | |
| 723 | /// Short, display-friendly summary of tool output. |
| 724 | fn summarize(content: &[ToolCallContent]) -> String { |
| 725 | content |
| 726 | .iter() |
| 727 | .map(|c| match c { |
| 728 | ToolCallContent::Content(c) => block_text(&c.content), |
| 729 | ToolCallContent::Diff(d) => { |
| 730 | let old = d.old_text.as_deref().map_or(0, |t| t.lines().count()); |
| 731 | format!( |
| 732 | "{} (-{old} +{} lines)", |
| 733 | d.path.display(), |
| 734 | d.new_text.lines().count() |
| 735 | ) |
| 736 | } |
| 737 | _ => String::new(), |
| 738 | }) |
| 739 | .filter(|s| !s.is_empty()) |
| 740 | .collect::<Vec<_>>() |
| 741 | .join("\n") |
| 742 | } |
| 743 | |
| 744 | #[cfg(test)] |
| 745 | mod fs_handler_tests { |
| 746 | use super::*; |
| 747 | |
| 748 | #[test] |
| 749 | fn read_text_file_slices_by_1_based_line_and_limit() { |
| 750 | let dir = std::env::temp_dir().join("ed-acp-tui-fs-test"); |
| 751 | std::fs::create_dir_all(&dir).unwrap(); |
| 752 | let file = dir.join("lines.txt"); |
| 753 | std::fs::write(&file, "one\ntwo\nthree\nfour\n").unwrap(); |
| 754 | |
| 755 | let all = read_text_file_blocking(&file, None, None).unwrap(); |
| 756 | assert_eq!(all.content, "one\ntwo\nthree\nfour"); |
| 757 | |
| 758 | let from_line = read_text_file_blocking(&file, Some(2), None).unwrap(); |
| 759 | assert_eq!(from_line.content, "two\nthree\nfour"); |
| 760 | |
| 761 | let limited = read_text_file_blocking(&file, Some(2), Some(1)).unwrap(); |
| 762 | assert_eq!(limited.content, "two"); |
| 763 | |
| 764 | let past_end = read_text_file_blocking(&file, Some(99), None).unwrap(); |
| 765 | assert_eq!(past_end.content, ""); |
| 766 | |
| 767 | std::fs::remove_dir_all(&dir).ok(); |
| 768 | } |
| 769 | |
| 770 | #[test] |
| 771 | fn read_text_file_missing_is_resource_not_found() { |
| 772 | let err = read_text_file_blocking(Path::new("/definitely/does/not/exist.txt"), None, None) |
| 773 | .unwrap_err(); |
| 774 | assert_eq!(err.code, agent_client_protocol::ErrorCode::ResourceNotFound); |
| 775 | } |
| 776 | |
| 777 | #[test] |
| 778 | fn write_text_file_creates_missing_file_and_parents() { |
| 779 | let dir = std::env::temp_dir() |
| 780 | .join("ed-acp-tui-fs-test-2") |
| 781 | .join("nested"); |
| 782 | let file = dir.join("created.txt"); |
| 783 | write_text_file_blocking(&file, "hello").unwrap(); |
| 784 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello"); |
| 785 | |
| 786 | // Overwrite existing. |
| 787 | write_text_file_blocking(&file, "again").unwrap(); |
| 788 | assert_eq!(std::fs::read_to_string(&file).unwrap(), "again"); |
| 789 | |
| 790 | std::fs::remove_dir_all(dir.parent().unwrap()).ok(); |
| 791 | } |
| 792 | } |