Add /delegate to coder-lite, backed by an ACP harness.

bcbde9153f74 · AtlantisPleb · · parent c6e7c8a94044

Add /delegate to coder-lite, backed by an ACP harness.

- New `acp_harness.rs` speaks ACP over stdio (JSON-RPC), handling
  `initialize`, `session/new`, `session/prompt`, `session/update`, and
  permission requests, streaming `tool_call` and `agent_message_chunk` events.
- `acp.rs` now records the discovered agent's launch command and args, and
  ensures `acp` is included for binary/npx/uvx modes.
- TUI recognizes `/delegate <agent> <prompt>`, spawns the ACP agent, and
  renders the `tool_call` as a one-line header plus a 5-line streaming output
  box.
- `Entry` gains `output: Option<Vec<String>>` for tool output streams; `Role`
  gets `PartialEq/Eq`.
- Adds `tests/tool_box.rs` asserting the delegate header and box are rendered.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/coder-lite/src/acp.rs
  • added crates/coder-lite/src/acp_harness.rs
  • modified crates/coder-lite/src/interactive.rs
  • modified crates/coder-lite/src/lib.rs
  • modified crates/coder-lite/src/tui.rs
  • modified crates/coder-lite/tests/markdown.rs
  • added crates/coder-lite/tests/tool_box.rs

Diff

7 files changed, +619 -15

crates/coder-lite/src/acp.rs modified +54

@@ -13,6 +13,10 @@ use std::path::{Path, PathBuf};

13 13
pub struct Agent {
14 14
    pub id: String,
15 15
    pub name: String,
16
    /// Command to run the agent, as found on this system.
17
    pub command: String,
18
    /// Args to pass after `command`.
19
    pub args: Vec<String>,
16 20
}
17 21
18 22
/// Discover all ACP agents in the registry that are also available locally.

@@ -57,9 +61,12 @@ pub async fn find_agents() -> Result<Vec<Agent>, Box<dyn std::error::Error>> {

57 61
        };
58 62
59 63
        if is_available(&agent, &npm_root, &uv_tools).await {
64
            let (command, args) = launch_for(&agent);
60 65
            found.push(Agent {
61 66
                id: agent.id,
62 67
                name: agent.name,
68
                command,
69
                args,
63 70
            });
64 71
        }
65 72
    }

@@ -147,6 +154,53 @@ async fn is_available(agent: &RegistryAgent, npm_root: &Option<String>, uv_tools

147 154
    false
148 155
}
149 156
157
fn launch_for(agent: &RegistryAgent) -> (String, Vec<String>) {
158
    let Some(dist) = &agent.distribution else {
159
        return (agent.id.clone(), Vec::new());
160
    };
161
162
    if let Some(binary) = &dist.binary {
163
        let platform = current_platform();
164
        if let Some(target) = binary.get(&platform) {
165
            let name = Path::new(&target.cmd)
166
                .file_name()
167
                .and_then(|s| s.to_str())
168
                .unwrap_or(&target.cmd)
169
                .to_string();
170
            let mut args = target.args.clone().unwrap_or_default();
171
            // ACP mode is the agent's streaming protocol.
172
            if !args.iter().any(|a| a == "acp") {
173
                args.push("acp".to_string());
174
            }
175
            return (name, args);
176
        }
177
    }
178
179
    if let Some(npx) = &dist.npx {
180
        let mut args = vec!["-y".to_string(), npx.package.clone()];
181
        if let Some(extra) = &npx.args {
182
            args.extend(extra.iter().cloned());
183
        }
184
        if !args.iter().any(|a| a == "acp") {
185
            args.push("acp".to_string());
186
        }
187
        return ("npx".to_string(), args);
188
    }
189
190
    if let Some(uvx) = &dist.uvx {
191
        let mut args = vec![uvx.package.clone()];
192
        if let Some(extra) = &uvx.args {
193
            args.extend(extra.iter().cloned());
194
        }
195
        if !args.iter().any(|a| a == "acp") {
196
            args.push("acp".to_string());
197
        }
198
        return ("uvx".to_string(), args);
199
    }
200
201
    (agent.id.clone(), Vec::new())
202
}
203
150 204
fn current_platform() -> String {
151 205
    use std::env::consts::{ARCH, OS};
152 206
    let os = match OS {
crates/coder-lite/src/acp_harness.rs added +361

@@ -0,0 +1,361 @@

1
//! ACP child agent harness for coder-lite.
2
//!
3
//! Spawns an ACP-compatible CLI agent over stdio and streams JSON-RPC
4
//! `session/update` events as they arrive.
5
6
use std::path::Path;
7
use std::process::Stdio;
8
use std::time::Duration;
9
10
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
11
use tokio::process::{ChildStdin, ChildStdout, Command};
12
13
#[derive(Debug)]
14
pub enum AcpFailure {
15
    Unstartable(String),
16
    Refused(String),
17
}
18
19
impl std::fmt::Display for AcpFailure {
20
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21
        match self {
22
            AcpFailure::Unstartable(why) => write!(f, "{why}"),
23
            AcpFailure::Refused(why) => write!(f, "{why}"),
24
        }
25
    }
26
}
27
28
#[derive(Debug, Clone)]
29
pub enum AcpEvent {
30
    Session { id: String },
31
    Tool { kind: String, title: String },
32
    Tokens { input: u64, output: u64 },
33
    Text { chunk: String },
34
}
35
36
#[derive(Debug, Clone)]
37
pub struct AcpHarness {
38
    pub command: String,
39
    pub args: Vec<String>,
40
}
41
42
impl Default for AcpHarness {
43
    fn default() -> Self {
44
        Self {
45
            command: "devin".to_string(),
46
            args: vec!["acp".to_string()],
47
        }
48
    }
49
}
50
51
const REQUEST_TIMEOUT: Duration = Duration::from_secs(900);
52
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
53
54
impl AcpHarness {
55
    pub async fn run<F>(
56
        &self,
57
        prompt: &str,
58
        cwd: &Path,
59
        mut on_event: F,
60
    ) -> Result<String, AcpFailure>
61
    where
62
        F: FnMut(AcpEvent) + Send,
63
    {
64
        let mut child = Command::new(&self.command)
65
            .args(&self.args)
66
            .current_dir(cwd)
67
            .stdin(Stdio::piped())
68
            .stdout(Stdio::piped())
69
            .stderr(Stdio::piped())
70
            .spawn()
71
            .map_err(|error| {
72
                AcpFailure::Unstartable(if error.kind() == std::io::ErrorKind::NotFound {
73
                    format!("the `{}` command is not on PATH", self.command)
74
                } else {
75
                    format!("the `{}` command would not start: {error}", self.command)
76
                })
77
            })?;
78
79
        if let Some(stderr) = child.stderr.take() {
80
            tokio::spawn(async move {
81
                let mut lines = BufReader::new(stderr).lines();
82
                while let Ok(Some(_)) = lines.next_line().await {}
83
            });
84
        }
85
86
        let stdin = child.stdin.take();
87
        let stdout = child.stdout.take();
88
        let (Some(mut stdin), Some(stdout)) = (stdin, stdout) else {
89
            let _ = child.kill().await;
90
            return Err(AcpFailure::Refused(
91
                "the agent's standard streams could not be opened".to_string(),
92
            ));
93
        };
94
        let mut lines = BufReader::new(stdout).lines();
95
96
        let outcome = self
97
            .converse(prompt, cwd, &mut stdin, &mut lines, &mut on_event)
98
            .await;
99
100
        let _ = child.kill().await;
101
        outcome
102
    }
103
104
    async fn converse<F>(
105
        &self,
106
        prompt: &str,
107
        cwd: &Path,
108
        stdin: &mut ChildStdin,
109
        lines: &mut tokio::io::Lines<BufReader<ChildStdout>>,
110
        on_event: &mut F,
111
    ) -> Result<String, AcpFailure>
112
    where
113
        F: FnMut(AcpEvent) + Send,
114
    {
115
        let mut seq: u64 = 0;
116
        let mut answer = String::new();
117
118
        request(
119
            stdin,
120
            lines,
121
            &mut seq,
122
            "initialize",
123
            serde_json::json!({
124
                "protocolVersion": 1,
125
                "clientCapabilities": {"fs": {"readTextFile": false, "writeTextFile": false}}
126
            }),
127
            HANDSHAKE_TIMEOUT,
128
            &mut answer,
129
            on_event,
130
        )
131
        .await?;
132
133
        let opened = request(
134
            stdin,
135
            lines,
136
            &mut seq,
137
            "session/new",
138
            serde_json::json!({"cwd": cwd.to_string_lossy(), "mcpServers": []}),
139
            REQUEST_TIMEOUT,
140
            &mut answer,
141
            on_event,
142
        )
143
        .await?;
144
145
        let session_id = opened
146
            .get("sessionId")
147
            .and_then(|v| v.as_str())
148
            .ok_or_else(|| AcpFailure::Refused("the agent opened no session".to_string()))?
149
            .to_string();
150
        on_event(AcpEvent::Session {
151
            id: session_id.clone(),
152
        });
153
154
        request(
155
            stdin,
156
            lines,
157
            &mut seq,
158
            "session/prompt",
159
            serde_json::json!({
160
                "sessionId": session_id,
161
                "prompt": [{"type": "text", "text": prompt}]
162
            }),
163
            REQUEST_TIMEOUT,
164
            &mut answer,
165
            on_event,
166
        )
167
        .await?;
168
169
        Ok(answer.trim().to_string())
170
    }
171
}
172
173
#[allow(clippy::too_many_arguments)]
174
async fn request<F>(
175
    stdin: &mut ChildStdin,
176
    lines: &mut tokio::io::Lines<BufReader<ChildStdout>>,
177
    seq: &mut u64,
178
    method: &str,
179
    params: serde_json::Value,
180
    limit: Duration,
181
    answer: &mut String,
182
    on_event: &mut F,
183
) -> Result<serde_json::Value, AcpFailure>
184
where
185
    F: FnMut(AcpEvent) + Send,
186
{
187
    *seq += 1;
188
    let id = *seq;
189
    let line = serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
190
    write_line(stdin, &line).await?;
191
192
    let deadline = tokio::time::Instant::now() + limit;
193
194
    loop {
195
        let next = tokio::select! {
196
            read = lines.next_line() => read,
197
            _ = tokio::time::sleep_until(deadline) => {
198
                return Err(AcpFailure::Refused(format!(
199
                    "the agent did not answer `{method}` within {}s", limit.as_secs()
200
                )));
201
            }
202
        };
203
204
        let raw = match next {
205
            Ok(Some(raw)) => raw,
206
            Ok(None) => {
207
                return Err(AcpFailure::Refused(
208
                    "the agent exited before it answered".to_string(),
209
                ))
210
            }
211
            Err(error) => {
212
                return Err(AcpFailure::Refused(format!(
213
                    "the agent's output could not be read: {error}"
214
                )))
215
            }
216
        };
217
218
        let trimmed = raw.trim();
219
        if trimmed.is_empty() {
220
            continue;
221
        }
222
        let Ok(message) = serde_json::from_str::<serde_json::Value>(trimmed) else {
223
            continue;
224
        };
225
226
        let is_reply = message.get("id").and_then(|v| v.as_u64()).is_some()
227
            && message.get("method").is_none();
228
        if is_reply {
229
            if message.get("id").and_then(|v| v.as_u64()) != Some(id) {
230
                continue;
231
            }
232
            if let Some(error) = message.get("error") {
233
                let text = serde_json::to_string(error).unwrap_or_default();
234
                return Err(AcpFailure::Refused(format!(
235
                    "the agent refused `{method}`: {}",
236
                    &text[..text.len().min(200)]
237
                )));
238
            }
239
            return Ok(message
240
                .get("result")
241
                .cloned()
242
                .unwrap_or(serde_json::json!({})));
243
        }
244
245
        handle_incoming(&message, stdin, answer, on_event).await?;
246
    }
247
}
248
249
async fn handle_incoming<F>(
250
    message: &serde_json::Value,
251
    stdin: &mut ChildStdin,
252
    answer: &mut String,
253
    on_event: &mut F,
254
) -> Result<(), AcpFailure>
255
where
256
    F: FnMut(AcpEvent) + Send,
257
{
258
    let method = message.get("method").and_then(|v| v.as_str()).unwrap_or("");
259
260
    if method == "session/request_permission" {
261
        let Some(id) = message.get("id").and_then(|v| v.as_u64()) else {
262
            return Ok(());
263
        };
264
        let params = message
265
            .get("params")
266
            .cloned()
267
            .unwrap_or(serde_json::json!({}));
268
        let outcome = match first_allow_option(&params) {
269
            Some(option) => serde_json::json!({"outcome": "selected", "optionId": option}),
270
            None => serde_json::json!({"outcome": "cancelled"}),
271
        };
272
        write_line(
273
            stdin,
274
            &serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {"outcome": outcome}}),
275
        )
276
        .await?;
277
        return Ok(());
278
    }
279
280
    if method != "session/update" {
281
        return Ok(());
282
    }
283
284
    let update = message
285
        .get("params")
286
        .and_then(|p| p.get("update"))
287
        .cloned()
288
        .unwrap_or(serde_json::json!({}));
289
290
    match update.get("sessionUpdate").and_then(|v| v.as_str()) {
291
        Some("tool_call") => {
292
            on_event(AcpEvent::Tool {
293
                kind: update
294
                    .get("kind")
295
                    .and_then(|v| v.as_str())
296
                    .unwrap_or("tool")
297
                    .to_string(),
298
                title: update
299
                    .get("title")
300
                    .and_then(|v| v.as_str())
301
                    .unwrap_or("")
302
                    .to_string(),
303
            });
304
        }
305
        Some("usage_update") => {
306
            let meta = update.get("_meta").cloned().unwrap_or(serde_json::json!({}));
307
            let input = meta.get("cognition.ai/inputTokens").and_then(|v| v.as_u64());
308
            let output = meta.get("cognition.ai/outputTokens").and_then(|v| v.as_u64());
309
            if let (Some(input), Some(output)) = (input, output) {
310
                on_event(AcpEvent::Tokens { input, output });
311
            }
312
        }
313
        Some("agent_message_chunk") => {
314
            if let Some(piece) = update
315
                .get("content")
316
                .and_then(|c| c.get("text"))
317
                .and_then(|v| v.as_str())
318
            {
319
                answer.push_str(piece);
320
                on_event(AcpEvent::Text {
321
                    chunk: piece.to_string(),
322
                });
323
            }
324
        }
325
        _ => {}
326
    }
327
328
    Ok(())
329
}
330
331
async fn write_line(stdin: &mut ChildStdin, value: &serde_json::Value) -> Result<(), AcpFailure> {
332
    let mut line = serde_json::to_string(value).unwrap_or_default();
333
    line.push('\n');
334
    stdin
335
        .write_all(line.as_bytes())
336
        .await
337
        .map_err(|error| AcpFailure::Refused(format!("the agent stopped reading: {error}")))?;
338
    stdin
339
        .flush()
340
        .await
341
        .map_err(|error| AcpFailure::Refused(format!("the agent stopped reading: {error}")))
342
}
343
344
fn first_allow_option(params: &serde_json::Value) -> Option<String> {
345
    let options = params.get("options")?.as_array()?;
346
    let named: Vec<&serde_json::Value> = options
347
        .iter()
348
        .filter(|option| option.get("optionId").and_then(|v| v.as_str()).is_some())
349
        .collect();
350
    let allow = named.iter().find(|option| {
351
        option
352
            .get("kind")
353
            .and_then(|v| v.as_str())
354
            .is_some_and(|kind| kind.starts_with("allow"))
355
    });
356
    allow
357
        .or(named.first())
358
        .and_then(|option| option.get("optionId"))
359
        .and_then(|v| v.as_str())
360
        .map(String::from)
361
}
crates/coder-lite/src/interactive.rs modified +129 -14

@@ -5,9 +5,11 @@

5 5
//! `modifiers` so control chords do not fall through to plain character input.
6 6
7 7
use crate::acp;
8
use crate::acp_harness::{AcpEvent, AcpHarness};
8 9
use crate::runtime::{CoderRuntimeSession, Control};
9 10
use crate::tui::{CoderUi, Entry, Role};
10 11
use std::sync::mpsc;
12
use std::path::PathBuf;
11 13
use crossterm::{
12 14
    event::{
13 15
        self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,

@@ -21,6 +23,14 @@ use ratatui::Terminal;

21 23
use std::io::{stderr, stdout};
22 24
use std::time::Duration;
23 25
26
#[derive(Debug, Clone)]
27
pub enum DelegateControl {
28
    Tool { agent: String, title: String },
29
    Text(String),
30
    Done,
31
    Error(String),
32
}
33
24 34
pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {
25 35
    if !atty_is_terminal() {
26 36
        println!("Non-interactive terminal detected. Run coder-lite from a TTY.");

@@ -42,6 +52,7 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

42 52
    terminal.show_cursor()?;
43 53
44 54
    let (tx, rx) = mpsc::channel::<Control>();
55
    let (delegate_tx, delegate_rx) = mpsc::channel::<DelegateControl>();
45 56
    let mut ui = CoderUi::new();
46 57
47 58
    match acp::find_agents().await {

@@ -54,12 +65,15 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

54 65
            ui.entries.push(Entry {
55 66
                role: Role::Notice,
56 67
                text: format!("found ACP agents: {}", list),
68
                output: None,
57 69
            });
70
            ui.agents = agents;
58 71
        }
59 72
        Err(_) => {
60 73
            ui.entries.push(Entry {
61 74
                role: Role::Notice,
62 75
                text: "found ACP agents: none".to_string(),
76
                output: None,
63 77
            });
64 78
        }
65 79
    }

@@ -77,6 +91,36 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

77 91
            }
78 92
        }
79 93
94
        while let Ok(delegate) = delegate_rx.try_recv() {
95
            match delegate {
96
                DelegateControl::Tool { agent, title } => {
97
                    ui.entries.push(Entry {
98
                        role: Role::Tool,
99
                        text: format!("delegate {}: {}", agent, title),
100
                        output: Some(Vec::new()),
101
                    });
102
                    ui.scroll_override = None;
103
                }
104
                DelegateControl::Text(chunk) => {
105
                    if let Some(last) = ui.entries.last_mut() {
106
                        if last.role == Role::Tool {
107
                            last.output.get_or_insert_with(Vec::new).push(chunk);
108
                        }
109
                    }
110
                    ui.scroll_override = None;
111
                }
112
                DelegateControl::Done => {}
113
                DelegateControl::Error(message) => {
114
                    ui.entries.push(Entry {
115
                        role: Role::Notice,
116
                        text: format!("delegate error: {}", message),
117
                        output: None,
118
                    });
119
                    ui.scroll_override = None;
120
                }
121
            }
122
        }
123
80 124
        terminal.draw(|f| {
81 125
            let size = f.area();
82 126
            ui.render(f, size);

@@ -106,23 +150,94 @@ pub async fn run_tui() -> Result<(), Box<dyn std::error::Error>> {

106 150
                            ui.composer.push('\n');
107 151
                        } else if !ui.composer.trim().is_empty() {
108 152
                            let prompt = ui.composer.clone();
109
                            ui.entries.push(Entry {
110
                                role: Role::You,
111
                                text: prompt.clone(),
112
                            });
113
                            ui.entries.push(Entry {
114
                                role: Role::Assistant,
115
                                text: String::new(),
116
                            });
117 153
                            ui.composer.clear();
118 154
                            ui.scroll_override = None;
119
                            ui.loading = true;
120 155
121
                            let mut session = CoderRuntimeSession::new();
122
                            let tx = tx.clone();
123
                            tokio::spawn(async move {
124
                                let _ = session.execute_turn(&prompt, tx).await;
125
                            });
156
                            if let Some(rest) = prompt.strip_prefix("/delegate") {
157
                                let rest = rest.trim_start();
158
                                if let Some((agent_id, task)) = rest.split_once(' ') {
159
                                    let agent_id = agent_id.to_string();
160
                                    let task = task.trim().to_string();
161
                                    let agent = ui.agents.iter().find(|a| a.id == agent_id).cloned();
162
                                    ui.entries.push(Entry {
163
                                        role: Role::You,
164
                                        text: prompt,
165
                                        output: None,
166
                                    });
167
                                    match agent {
168
                                        Some(agent) => {
169
                                            ui.entries.push(Entry {
170
                                                role: Role::Tool,
171
                                                text: format!("delegate {}: starting", agent_id),
172
                                                output: Some(Vec::new()),
173
                                            });
174
                                            let cwd =
175
                                                std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
176
                                            let delegate_tx = delegate_tx.clone();
177
                                            tokio::spawn(async move {
178
                                                let harness = AcpHarness {
179
                                                    command: agent.command,
180
                                                    args: agent.args,
181
                                                };
182
                                                let result = harness
183
                                                    .run(&task, &cwd, |event| match event {
184
                                                        AcpEvent::Tool { kind: _, title } => {
185
                                                            let _ = delegate_tx.send(
186
                                                                DelegateControl::Tool {
187
                                                                    agent: agent_id.clone(),
188
                                                                    title,
189
                                                                },
190
                                                            );
191
                                                        }
192
                                                        AcpEvent::Text { chunk } => {
193
                                                            let _ = delegate_tx
194
                                                                .send(DelegateControl::Text(chunk));
195
                                                        }
196
                                                        _ => {}
197
                                                    })
198
                                                    .await;
199
                                                if let Err(e) = result {
200
                                                    let _ = delegate_tx
201
                                                        .send(DelegateControl::Error(e.to_string()));
202
                                                } else {
203
                                                    let _ = delegate_tx.send(DelegateControl::Done);
204
                                                }
205
                                            });
206
                                        }
207
                                        None => {
208
                                            ui.entries.push(Entry {
209
                                                role: Role::Notice,
210
                                                text: format!("unknown ACP agent: {}", agent_id),
211
                                                output: None,
212
                                            });
213
                                        }
214
                                    }
215
                                } else {
216
                                    ui.entries.push(Entry {
217
                                        role: Role::Notice,
218
                                        text: "usage: /delegate <agent> <prompt>".to_string(),
219
                                        output: None,
220
                                    });
221
                                }
222
                            } else {
223
                                ui.entries.push(Entry {
224
                                    role: Role::You,
225
                                    text: prompt.clone(),
226
                                    output: None,
227
                                });
228
                                ui.entries.push(Entry {
229
                                    role: Role::Assistant,
230
                                    text: String::new(),
231
                                    output: None,
232
                                });
233
                                ui.loading = true;
234
235
                                let mut session = CoderRuntimeSession::new();
236
                                let tx = tx.clone();
237
                                tokio::spawn(async move {
238
                                    let _ = session.execute_turn(&prompt, tx).await;
239
                                });
240
                            }
126 241
                        }
127 242
                    }
128 243
                    KeyEvent {
crates/coder-lite/src/lib.rs modified +1

@@ -1,6 +1,7 @@

1 1
//! coder-lite: a minimal ratatui TUI boot crate
2 2
3 3
pub mod acp;
4
pub mod acp_harness;
4 5
pub mod interactive;
5 6
pub mod runtime;
6 7
pub mod tui;
crates/coder-lite/src/tui.rs modified +32 -1

@@ -88,7 +88,7 @@ impl RichTextTheme for CoderTheme {

88 88
    }
89 89
}
90 90
91
#[derive(Debug, Clone)]
91
#[derive(Debug, Clone, PartialEq, Eq)]
92 92
pub enum Role {
93 93
    You,
94 94
    Assistant,

@@ -101,6 +101,7 @@ pub enum Role {

101 101
pub struct Entry {
102 102
    pub role: Role,
103 103
    pub text: String,
104
    pub output: Option<Vec<String>>,
104 105
}
105 106
106 107
#[derive(Debug)]

@@ -118,6 +119,7 @@ pub struct CoderUi {

118 119
    pub transcript_height: u16,
119 120
    pub loading: bool,
120 121
    pub tick: u64,
122
    pub agents: Vec<crate::acp::Agent>,
121 123
}
122 124
123 125
fn wrap_text(text: &str, width: usize) -> Vec<String> {

@@ -212,6 +214,7 @@ impl CoderUi {

212 214
            transcript_height: 0,
213 215
            loading: false,
214 216
            tick: 0,
217
            agents: Vec::new(),
215 218
        }
216 219
    }
217 220

@@ -320,6 +323,34 @@ impl CoderUi {

320 323
321 324
                lines
322 325
            }
326
            Role::Tool => {
327
                let text_style = Style::default().fg(TEXT_COLOR).bg(BACKGROUND_COLOR);
328
                let mut lines = Vec::new();
329
330
                // One-line tool call header.
331
                let header_body = width.saturating_sub(4);
332
                let header_chunks = wrap_text(&entry.text, header_body);
333
                let header = header_chunks.first().cloned().unwrap_or_default();
334
                lines.push(Line::from(vec![
335
                    Span::styled("  ⏺ ", text_style),
336
                    Span::styled(header, text_style),
337
                ]));
338
339
                // ~5-line output box, one chunk per line.
340
                let out = entry.output.as_ref().map_or(&[][..], |v| v.as_slice());
341
                let start = out.len().saturating_sub(5);
342
                let window = &out[start..];
343
                for i in 0..5 {
344
                    let text = window.get(i).map(String::as_str).unwrap_or("");
345
                    let clipped = text.chars().take(width.saturating_sub(4)).collect::<String>();
346
                    lines.push(Line::from(vec![
347
                        Span::styled("  │ ", text_style),
348
                        Span::styled(clipped, text_style),
349
                    ]));
350
                }
351
352
                lines
353
            }
323 354
            _ => {
324 355
                let text_style = Style::default().fg(TEXT_COLOR).bg(BACKGROUND_COLOR);
325 356
crates/coder-lite/tests/markdown.rs modified +1

@@ -8,6 +8,7 @@ fn renders_markdown_bold_and_italic() {

8 8
    ui.entries.push(Entry {
9 9
        role: Role::Assistant,
10 10
        text: "**bold** and *italic*".to_string(),
11
        output: None,
11 12
    });
12 13
13 14
    let backend = TestBackend::new(80, 24);
crates/coder-lite/tests/tool_box.rs added +41

@@ -0,0 +1,41 @@

1
use coder_lite::tui::{CoderUi, Entry, Role};
2
use ratatui::Terminal;
3
use ratatui::backend::TestBackend;
4
5
#[test]
6
fn renders_delegate_tool_call_and_five_line_box() {
7
    let mut ui = CoderUi::new();
8
    ui.entries.push(Entry {
9
        role: Role::Tool,
10
        text: "delegate devin: Read src/main.rs".to_string(),
11
        output: Some(vec![
12
            "Reading file...".to_string(),
13
            "Found main()".to_string(),
14
            "Done".to_string(),
15
        ]),
16
    });
17
18
    let backend = TestBackend::new(80, 24);
19
    let mut terminal = Terminal::new(backend).unwrap();
20
21
    terminal
22
        .draw(|f| {
23
            let area = f.area();
24
            ui.render(f, area);
25
        })
26
        .unwrap();
27
28
    let text = terminal
29
        .backend()
30
        .buffer()
31
        .content
32
        .iter()
33
        .map(|c| c.symbol())
34
        .collect::<String>();
35
36
    assert!(text.contains("delegate devin: Read src/main.rs"), "{}", text);
37
    assert!(text.contains("Reading file..."), "{}", text);
38
    assert!(text.contains("Found main()"), "{}", text);
39
    assert!(text.contains("Done"), "{}", text);
40
    assert!(text.contains("│"), "box border not rendered: {}", text);
41
}

This page updates live while a promote is in flight · changelog