main
rs 792 lines 28.7 KB
Raw
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 }