@ondeinference / ed / commits / c058adb

Add ed-acp-tui: a terminal UI for any ACP agent

A regular ACP client that launches the agent as a subprocess and renders one session with ratatui. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: siGit Code v1.6.3 <noreply@sigit.si>

Seto Elkahfi committed Oct 9, 2026 at 00:12 UTC c058adb4bd1c2d305321a2d8fa2361ea958c4c04
5 files changed +1030
Cargo.lock
+203
@@ -790,6 +790,12 @@ version = "3.20.3"
790 source = "registry+https://github.com/rust-lang/crates.io-index"
791 checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
792
793 +[[package]]
794 +name = "by_address"
795 +version = "1.2.1"
796 +source = "registry+https://github.com/rust-lang/crates.io-index"
797 +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
798 +
799 [[package]]
800 name = "bytemuck"
801 version = "1.25.2"
@@ -1315,6 +1321,12 @@ dependencies = [
1321 "cfg-if",
1322 ]
1323
1324 +[[package]]
1325 +name = "critical-section"
1326 +version = "1.2.0"
1327 +source = "registry+https://github.com/rust-lang/crates.io-index"
1328 +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
1329 +
1330 [[package]]
1331 name = "crossbeam-channel"
1332 version = "0.5.17"
@@ -1359,6 +1371,7 @@ dependencies = [
1371 "crossterm_winapi",
1372 "derive_more",
1373 "document-features",
1374 + "futures-core",
1375 "mio",
1376 "parking_lot",
1377 "rustix",
@@ -1973,6 +1986,18 @@ dependencies = [
1986 "uuid 1.27.0",
1987 ]
1988
1989 +[[package]]
1990 +name = "ed-acp-tui"
1991 +version = "1.0.1"
1992 +dependencies = [
1993 + "agent-client-protocol",
1994 + "anyhow",
1995 + "crossterm",
1996 + "futures",
1997 + "ratatui",
1998 + "tokio",
1999 +]
2000 +
2001 [[package]]
2002 name = "ed-agent"
2003 version = "1.0.1"
@@ -3188,6 +3213,11 @@ name = "hashbrown"
3213 version = "0.17.1"
3214 source = "registry+https://github.com/rust-lang/crates.io-index"
3215 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
3216 +dependencies = [
3217 + "allocator-api2",
3218 + "equivalent",
3219 + "foldhash 0.2.0",
3220 +]
3221
3222 [[package]]
3223 name = "heck"
@@ -3655,6 +3685,15 @@ dependencies = [
3685 "web-time",
3686 ]
3687
3688 +[[package]]
3689 +name = "indoc"
3690 +version = "2.0.7"
3691 +source = "registry+https://github.com/rust-lang/crates.io-index"
3692 +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
3693 +dependencies = [
3694 + "rustversion",
3695 +]
3696 +
3697 [[package]]
3698 name = "infer"
3699 version = "0.22.0"
@@ -3664,6 +3703,19 @@ dependencies = [
3703 "cfb",
3704 ]
3705
3706 +[[package]]
3707 +name = "instability"
3708 +version = "0.3.14"
3709 +source = "registry+https://github.com/rust-lang/crates.io-index"
3710 +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
3711 +dependencies = [
3712 + "darling 0.24.1",
3713 + "indoc",
3714 + "proc-macro2",
3715 + "quote",
3716 + "syn 3.0.6",
3717 +]
3718 +
3719 [[package]]
3720 name = "interpolate_name"
3721 version = "0.2.4"
@@ -3909,6 +3961,17 @@ dependencies = [
3961 "serde_json",
3962 ]
3963
3964 +[[package]]
3965 +name = "kasuari"
3966 +version = "0.4.12"
3967 +source = "registry+https://github.com/rust-lang/crates.io-index"
3968 +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
3969 +dependencies = [
3970 + "hashbrown 0.16.1",
3971 + "portable-atomic",
3972 + "thiserror 2.0.21",
3973 +]
3974 +
3975 [[package]]
3976 name = "keyboard-types"
3977 version = "0.8.3"
@@ -4026,6 +4089,15 @@ dependencies = [
4089 "libc",
4090 ]
4091
4092 +[[package]]
4093 +name = "line-clipping"
4094 +version = "0.3.8"
4095 +source = "registry+https://github.com/rust-lang/crates.io-index"
4096 +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
4097 +dependencies = [
4098 + "bitflags 2.13.2",
4099 +]
4100 +
4101 [[package]]
4102 name = "linux-raw-sys"
4103 version = "0.12.1"
@@ -4111,6 +4183,15 @@ dependencies = [
4183 "vob",
4184 ]
4185
4186 +[[package]]
4187 +name = "lru"
4188 +version = "0.18.5"
4189 +source = "registry+https://github.com/rust-lang/crates.io-index"
4190 +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
4191 +dependencies = [
4192 + "hashbrown 0.17.1",
4193 +]
4194 +
4195 [[package]]
4196 name = "lru-slab"
4197 version = "0.1.3"
@@ -4657,6 +4738,15 @@ dependencies = [
4738 "syn 2.0.119",
4739 ]
4740
4741 +[[package]]
4742 +name = "num_threads"
4743 +version = "0.1.7"
4744 +source = "registry+https://github.com/rust-lang/crates.io-index"
4745 +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
4746 +dependencies = [
4747 + "libc",
4748 +]
4749 +
4750 [[package]]
4751 name = "number_prefix"
4752 version = "0.4.0"
@@ -5351,6 +5441,39 @@ dependencies = [
5441 "num-traits",
5442 ]
5443
5444 +[[package]]
5445 +name = "palette"
5446 +version = "0.7.7"
5447 +source = "registry+https://github.com/rust-lang/crates.io-index"
5448 +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
5449 +dependencies = [
5450 + "approx",
5451 + "libm",
5452 + "palette_derive",
5453 + "palette_math",
5454 +]
5455 +
5456 +[[package]]
5457 +name = "palette_derive"
5458 +version = "0.7.7"
5459 +source = "registry+https://github.com/rust-lang/crates.io-index"
5460 +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
5461 +dependencies = [
5462 + "by_address",
5463 + "proc-macro2",
5464 + "quote",
5465 + "syn 2.0.119",
5466 +]
5467 +
5468 +[[package]]
5469 +name = "palette_math"
5470 +version = "0.7.7"
5471 +source = "registry+https://github.com/rust-lang/crates.io-index"
5472 +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
5473 +dependencies = [
5474 + "libm",
5475 +]
5476 +
5477 [[package]]
5478 name = "pango"
5479 version = "0.18.3"
@@ -6002,6 +6125,73 @@ dependencies = [
6125 "rand_core 0.10.1",
6126 ]
6127
6128 +[[package]]
6129 +name = "ratatui"
6130 +version = "0.30.2"
6131 +source = "registry+https://github.com/rust-lang/crates.io-index"
6132 +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
6133 +dependencies = [
6134 + "instability",
6135 + "ratatui-core",
6136 + "ratatui-crossterm",
6137 + "ratatui-widgets",
6138 + "serde",
6139 +]
6140 +
6141 +[[package]]
6142 +name = "ratatui-core"
6143 +version = "0.1.2"
6144 +source = "registry+https://github.com/rust-lang/crates.io-index"
6145 +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
6146 +dependencies = [
6147 + "bitflags 2.13.2",
6148 + "compact_str",
6149 + "critical-section",
6150 + "hashbrown 0.17.1",
6151 + "itertools",
6152 + "kasuari",
6153 + "lru",
6154 + "palette",
6155 + "serde",
6156 + "strum 0.28.0",
6157 + "thiserror 2.0.21",
6158 + "unicode-segmentation",
6159 + "unicode-truncate",
6160 + "unicode-width 0.2.2",
6161 +]
6162 +
6163 +[[package]]
6164 +name = "ratatui-crossterm"
6165 +version = "0.1.2"
6166 +source = "registry+https://github.com/rust-lang/crates.io-index"
6167 +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
6168 +dependencies = [
6169 + "cfg-if",
6170 + "crossterm",
6171 + "instability",
6172 + "ratatui-core",
6173 +]
6174 +
6175 +[[package]]
6176 +name = "ratatui-widgets"
6177 +version = "0.3.2"
6178 +source = "registry+https://github.com/rust-lang/crates.io-index"
6179 +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
6180 +dependencies = [
6181 + "bitflags 2.13.2",
6182 + "hashbrown 0.17.1",
6183 + "indoc",
6184 + "instability",
6185 + "itertools",
6186 + "line-clipping",
6187 + "ratatui-core",
6188 + "serde",
6189 + "strum 0.28.0",
6190 + "time",
6191 + "unicode-segmentation",
6192 + "unicode-width 0.2.2",
6193 +]
6194 +
6195 [[package]]
6196 name = "rav1e"
6197 version = "0.8.1"
@@ -8167,7 +8357,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
8357 checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
8358 dependencies = [
8359 "deranged",
8360 + "libc",
8361 "num-conv",
8362 + "num_threads",
8363 "powerfmt",
8364 "serde_core",
8365 "time-core",
@@ -8775,6 +8967,17 @@ version = "1.13.3"
8967 source = "registry+https://github.com/rust-lang/crates.io-index"
8968 checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
8969
8970 +[[package]]
8971 +name = "unicode-truncate"
8972 +version = "2.0.1"
8973 +source = "registry+https://github.com/rust-lang/crates.io-index"
8974 +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
8975 +dependencies = [
8976 + "itertools",
8977 + "unicode-segmentation",
8978 + "unicode-width 0.2.2",
8979 +]
8980 +
8981 [[package]]
8982 name = "unicode-width"
8983 version = "0.1.14"
Cargo.toml
+2
@@ -6,6 +6,7 @@ members = [
6 "crates/ed-agent-tauri",
7 "crates/ed-mcp",
8 "crates/ed-acp",
9 + "crates/ed-acp-tui",
10 "crates/ed-acp-testkit",
11 "uniffi-bindgen",
12 ]
@@ -59,6 +60,7 @@ base64 = "0.23"
60 ed-agent = { version = "1.0.1", path = "crates/ed-agent" }
61 ed-mcp = { version = "1.0.1", path = "crates/ed-mcp" }
62 ed-acp = { version = "1.0.1", path = "crates/ed-acp" }
63 +ed-acp-tui = { version = "1.0.1", path = "crates/ed-acp-tui" }
64 ed-acp-testkit = { version = "1.0.1", path = "crates/ed-acp-testkit" }
65
66 # gemm-common (via candle, via onde) depends on sysctl for every
crates/ed-acp-tui/Cargo.toml new
+20
@@ -0,0 +1,20 @@
1 +[package]
2 +name = "ed-acp-tui"
3 +description = "A ratatui terminal UI for any Agent Client Protocol (ACP) agent: launches the agent as a subprocess and drives one session over stdio."
4 +keywords = ["acp", "agent", "tui", "terminal", "onde"]
5 +categories = ["command-line-interface", "development-tools"]
6 +version.workspace = true
7 +edition = "2024"
8 +rust-version.workspace = true
9 +license.workspace = true
10 +authors.workspace = true
11 +repository.workspace = true
12 +homepage.workspace = true
13 +
14 +[dependencies]
15 +agent-client-protocol.workspace = true
16 +ratatui.workspace = true
17 +crossterm.workspace = true
18 +tokio = { workspace = true, features = ["full"] }
19 +futures.workspace = true
20 +anyhow.workspace = true
crates/ed-acp-tui/README.md new
+13
@@ -0,0 +1,13 @@
1 +# ed-acp-tui
2 +
3 +A terminal UI for any [Agent Client Protocol](https://agentclientprotocol.com) agent.
4 +
5 +It is a regular ACP client: it launches the agent (usually the same binary with `--acp`) as a
6 +subprocess and renders one session with ratatui, so it exercises the protocol path an editor
7 +would. It serves `fs/read_text_file` and `fs/write_text_file` from local disk and answers
8 +permission requests with `y` (allow), `a` (always), `n` (reject) or `r` (always reject).
9 +
10 +## Copyright
11 +
12 +© 2026 [Splitfire AB](https://5mb.app) ([Onde Inference](https://ondeinference.com)).
13 +Licensed under MIT or Apache-2.0.
crates/ed-acp-tui/src/lib.rs new
+792
@@ -0,0 +1,792 @@
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 +}