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
+}