1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
//! Tool execution component for displaying tool calls.
//!
//! Based on TypeScript implementation:
//! packages/coding-agent/src/modes/interactive/components/tool-execution.ts
use std::any::Any;
use std::sync::Mutex;
use super::component::Component;
use crate::ansi::{bold, strip_ansi};
use crate::theme::theme;
use crate::utils::{apply_background_to_line, truncate_to_width};
/// Maximum diff lines rendered inline before collapsing the rest.
const DIFF_LINE_CAP: usize = 40;
/// Tool execution status.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolStatus {
/// Tool is pending execution
Pending,
/// Tool is running
Running,
/// Tool completed successfully
Completed,
/// Tool failed
Failed,
}
/// Component that displays a tool execution.
///
/// Mirrors TypeScript ToolExecutionComponent class (simplified).
pub struct ToolExecutionComponent {
/// Tool name
name: Mutex<String>,
/// Tool arguments (displayed)
args: Mutex<String>,
/// Tool result (displayed after execution)
result: Mutex<Option<String>>,
/// Execution status
status: Mutex<ToolStatus>,
/// Whether expanded
expanded: Mutex<bool>,
/// Optional pre-rendered colored diff lines (from `render_diff`). When
/// present the diff is always shown regardless of `expanded` — the diff
/// IS the useful content for an `edit` tool.
diff_lines: Mutex<Option<Vec<String>>>,
}
impl ToolExecutionComponent {
/// Create a new tool execution component.
pub fn new(name: &str, args: &str) -> Self {
Self {
name: Mutex::new(name.to_string()),
args: Mutex::new(args.to_string()),
result: Mutex::new(None),
status: Mutex::new(ToolStatus::Pending),
expanded: Mutex::new(false),
diff_lines: Mutex::new(None),
}
}
/// Set the tool arguments.
pub fn set_args(&self, args: &str) {
if let Ok(mut a) = self.args.lock() {
*a = args.to_string();
}
}
/// Set the tool result.
pub fn set_result(&self, result: &str, is_error: bool) {
if let Ok(mut r) = self.result.lock() {
// The header glyph + bg tint already convey status (✗/red for
// failure, ✓/green for success), so the result text is stored
// raw — no redundant emoji prefix (pi doesn't double these up).
*r = Some(result.to_string());
}
if let Ok(mut s) = self.status.lock() {
*s = if is_error { ToolStatus::Failed } else { ToolStatus::Completed };
}
}
/// Mark as running.
pub fn set_running(&self) {
if let Ok(mut s) = self.status.lock() {
*s = ToolStatus::Running;
}
}
/// Set expanded state.
pub fn set_expanded(&self, expanded: bool) {
if let Ok(mut e) = self.expanded.lock() {
*e = expanded;
}
}
/// Whether the component is currently expanded (for toggle helpers).
pub fn is_expanded(&self) -> bool {
*self.expanded.lock().unwrap()
}
/// Get the status.
pub fn status(&self) -> ToolStatus {
*self.status.lock().unwrap()
}
/// Get the tool name.
pub fn name(&self) -> String {
self.name.lock().unwrap().clone()
}
/// Attach a pre-rendered colored diff (from [`crate::diff::render_diff`]).
/// When set, the diff lines are always shown (regardless of `expanded`)
/// so an edit's changes are visible directly in the transcript.
pub fn set_diff(&self, lines: Vec<String>) {
if let Ok(mut d) = self.diff_lines.lock() {
*d = Some(lines);
}
}
}
impl Component for ToolExecutionComponent {
fn render(&self, width: usize) -> Vec<String> {
let colors = theme().colors;
let mut lines = Vec::new();
let name = self.name.lock().unwrap();
let status = self.status.lock().unwrap();
let args = self.args.lock().unwrap();
let result = self.result.lock().unwrap();
let expanded = self.expanded.lock().unwrap();
let diff_lines = self.diff_lines.lock().unwrap();
// Status indicator — a colored glyph (no emoji), pi style.
let (status_icon, status_color) = match *status {
ToolStatus::Pending => ("●", colors.muted),
ToolStatus::Running => ("●", colors.accent),
ToolStatus::Completed => ("✓", colors.success),
ToolStatus::Failed => ("✗", colors.error),
};
// Background tint for the header row reflects status (pi tool*Bg).
let bg = match *status {
ToolStatus::Pending | ToolStatus::Running => colors.tool_pending_bg,
ToolStatus::Failed => colors.tool_error_bg,
ToolStatus::Completed => colors.tool_success_bg,
};
// Tool header line: "● Tool: name ▶" themed, padded, bg-tinted.
let chevron = if *expanded { "▾" } else { "▸" };
let header_inner = format!(
"{} {} {} {}",
status_color.fg(status_icon),
colors.tool_title.fg(&bold("Tool:")),
colors.tool_title.fg(&bold(&name)),
colors.muted.fg(chevron),
);
let header_line = apply_background_to_line(&format!(" {}", header_inner), width, |s| bg.bg(s));
lines.push(header_line);
// If expanded, show args above the result. The result itself (and the
// diff) are always shown — they are the useful payload; `expanded`
// only gates the verbose args block.
if *expanded {
if !args.is_empty() {
let args_line = format!(" {} {}", colors.muted.fg("Args:"), args);
let args_line = apply_background_to_line(&args_line, width, |s| bg.bg(s));
lines.push(args_line);
}
}
// Show result if available (always — a failure's error text is the
// payload, not a detail to hide behind expand).
if let Some(ref r) = *result {
for line in r.lines() {
let result_line = format!(" {}", colors.tool_output.fg(line));
let result_line = apply_background_to_line(&result_line, width, |s| bg.bg(s));
lines.push(result_line);
}
} else if diff_lines.is_none() && !*expanded {
// No result yet, no diff, and collapsed: pad one bg-tinted row so
// the tool block still reads as a block (pi keeps the bg band).
let pad = apply_background_to_line("", width, |s| bg.bg(s));
lines.push(pad);
}
// A colored diff (from `render_diff`) is always shown — the diff IS
// the useful content for an edit; the summary header + (optional)
// expanded args sit above it. Cap to keep large diffs readable.
if let Some(diff) = diff_lines.as_ref() {
let total = diff.len();
let shown = diff.iter().take(DIFF_LINE_CAP);
for dl in shown {
// Strip any leading ANSI-styled padding-less body: diff lines
// are already complete (colors + content). Indent by 2 cols.
let body = strip_ansi(dl);
if body.is_empty() {
lines.push(String::new());
} else {
let indented = format!(" {}", dl);
lines.push(truncate_to_width(&indented, width, "…"));
}
}
if total > DIFF_LINE_CAP {
let more = total - DIFF_LINE_CAP;
lines.push(format!(" {}", colors.muted.fg(&format!("… {} more diff lines hidden", more))));
}
}
lines
}
fn invalidate(&self) {
// Tool has no cached state
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_execution_basic() {
let tool = ToolExecutionComponent::new("read", "file.txt");
let lines = tool.render(80);
assert!(!lines.is_empty());
}
#[test]
fn test_tool_execution_status() {
let tool = ToolExecutionComponent::new("bash", "ls -la");
assert_eq!(tool.status(), ToolStatus::Pending);
tool.set_running();
assert_eq!(tool.status(), ToolStatus::Running);
tool.set_result("output", false);
assert_eq!(tool.status(), ToolStatus::Completed);
}
#[test]
fn test_tool_execution_expanded() {
let tool = ToolExecutionComponent::new("edit", "file.rs");
tool.set_expanded(true);
tool.set_result("success", false);
let lines = tool.render(80);
assert!(lines.len() > 1); // Should have more lines when expanded
}
#[test]
fn test_tool_execution_error() {
let tool = ToolExecutionComponent::new("bash", "invalid_command");
tool.set_result("command not found", true);
let lines = tool.render(80);
let joined = lines.join("\n");
// Failed state: error-colored ✗ glyph + the result text echoed
// (expanded path includes the `❌ command not found` result line).
assert!(joined.contains("command not found"), "result text missing: {joined}");
assert!(joined.contains('✗'), "error glyph missing: {joined}");
}
}