1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use super::Request;
12
13pub const HEAD: usize = 20;
15
16const MAX_BYTES: usize = 1024 * 1024;
18
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
21pub struct PresentContext {
22 pub root: Option<PathBuf>,
24 pub sandbox: Option<String>,
26 pub network: String,
28 pub file_text: Option<String>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct Choice {
35 pub choice: String,
37 pub key: char,
39 pub number: u8,
41 pub label: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Presentation {
48 pub request: Request,
50 pub title: String,
53 pub subject: String,
55 pub body: Vec<String>,
57 pub full: Vec<String>,
59 pub sandbox: String,
61 pub options: Vec<Choice>,
63 pub scope: String,
65 pub old_text: Option<String>,
67 pub new_text: Option<String>,
69 pub file: Option<String>,
71}
72
73#[must_use]
76pub fn escape(text: &str) -> String {
77 text.chars()
78 .map(|c| match c {
79 '\x1b' => "␛".to_owned(),
80 '\n' | '\t' => c.to_string(),
81 c if (c as u32) < 0x20 => format!("^{}", char::from(b'@' + c as u8)),
82 '\x7f' => "^?".to_owned(),
83 c => c.to_string(),
84 })
85 .collect()
86}
87
88fn plain(a: &str) -> bool {
90 !a.is_empty()
91 && a.chars()
92 .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%".contains(c))
93}
94
95#[must_use]
97pub fn shell_quote(argv: &[String]) -> String {
98 argv.iter()
99 .map(|a| {
100 if plain(a) {
101 a.clone()
102 } else {
103 format!("'{}'", a.replace('\'', "'\\''"))
104 }
105 })
106 .collect::<Vec<_>>()
107 .join(" ")
108}
109
110#[must_use]
112pub fn scope_text(kind: &str) -> &'static str {
113 match kind {
114 "run" => "this exact command",
115 "network" => "this exact command with the network",
116 "mcp" => "any call of this MCP tool, any arguments",
117 _ => "this path, any content",
118 }
119}
120
121fn head(lines: &[String]) -> (Vec<String>, usize) {
122 let shown = lines.iter().take(HEAD).cloned().collect();
123 (shown, lines.len().saturating_sub(HEAD))
124}
125
126fn numbered(text: &str) -> Vec<String> {
128 text.lines()
129 .enumerate()
130 .map(|(i, l)| format!("{:>5} │ {}", i + 1, escape(l)))
131 .collect()
132}
133
134#[must_use]
136pub fn diff(old: &str, new: &str) -> Vec<String> {
137 let d = similar::TextDiff::from_lines(old, new);
138 let mut out = Vec::new();
139 for (i, group) in d.grouped_ops(3).iter().enumerate() {
140 if i > 0 {
141 out.push(" ┈".to_owned());
142 }
143 for op in group {
144 for change in d.iter_changes(op) {
145 let (sign, old_no, new_no) = match change.tag() {
146 similar::ChangeTag::Equal => (' ', change.old_index(), change.new_index()),
147 similar::ChangeTag::Delete => ('-', change.old_index(), None),
148 similar::ChangeTag::Insert => ('+', None, change.new_index()),
149 };
150 let no = |n: Option<usize>| n.map_or(String::new(), |n| (n + 1).to_string());
151 let line = format!(
152 "{:>5} {:>5} {sign} {}",
153 no(old_no),
154 no(new_no),
155 escape(change.value().trim_end_matches(['\n', '\r']))
156 );
157 out.push(line.trim_end().to_owned());
158 }
159 }
160 }
161 out
162}
163
164fn read(context: &PresentContext, path: &str) -> Result<Option<String>, String> {
166 if let Some(text) = &context.file_text {
167 return Ok(Some(text.clone()));
168 }
169 let Some(root) = context.root.as_deref() else {
170 return Ok(None);
171 };
172 let file = root.join(path);
173 let Ok(bytes) = std::fs::read(&file) else {
174 return Ok(None);
175 };
176 if bytes.len() > MAX_BYTES {
177 return Err(format!("{} bytes — too large to show", bytes.len()));
178 }
179 String::from_utf8(bytes)
180 .map(Some)
181 .map_err(|_| "a binary file — not shown".to_owned())
182}
183
184#[must_use]
186pub fn present(request: &Request, context: &PresentContext) -> Presentation {
187 let args = &request.arguments;
188 let s = |k: &str| args[k].as_str().unwrap_or_default().to_owned();
189 let path = s("path");
190 let kind = request.kind.as_str();
191 let mut old_text = None;
192 let mut new_text = None;
193 let (title, subject, full) = match kind {
194 "run" | "network" => {
195 let argv: Vec<String> = args["argv"]
196 .as_array()
197 .into_iter()
198 .flatten()
199 .filter_map(|v| v.as_str().map(str::to_owned))
200 .collect();
201 let cmd = escape(&shell_quote(&argv));
202 let cwd = args["cwd"]
203 .as_str()
204 .filter(|c| !c.is_empty())
205 .unwrap_or(".");
206 let net = if kind == "network" {
207 "this call".to_owned()
208 } else {
209 context.network.clone()
210 };
211 (
212 if kind == "network" {
213 "Use the network?"
214 } else {
215 "Run this command?"
216 },
217 cmd.clone(),
218 vec![format!("$ {cmd}"), format!("cwd: {cwd} · network: {net}")],
219 )
220 }
221 "mcp" => {
222 let pretty = serde_json::to_string_pretty(args).unwrap_or_default();
223 (
224 "Call MCP tool?",
225 request.tool.clone(),
226 pretty.lines().map(escape).collect(),
227 )
228 }
229 _ => {
230 let current = read(context, &path);
231 let title = if kind == "write" && matches!(current, Ok(None)) {
233 "Write this file?"
234 } else {
235 "Edit this file?"
236 };
237 let lines = match current {
238 Err(note) => vec![note],
239 Ok(current) => match (request.tool.as_str(), current) {
240 ("write_file", None) => {
241 let content = s("content");
242 new_text = Some(content.clone());
243 numbered(&content)
244 }
245 ("write_file", Some(old)) => {
246 let content = s("content");
247 let d = diff(&old, &content);
248 old_text = Some(old);
249 new_text = Some(content);
250 d
251 }
252 (_, Some(old)) => {
253 let (from, to) = (s("old_string"), s("new_string"));
254 let all = args["replace_all"] == true;
255 if !from.is_empty() && old.contains(&from) {
256 let new = if all {
257 old.replace(&from, &to)
258 } else {
259 old.replacen(&from, &to, 1)
260 };
261 let d = diff(&old, &new);
262 old_text = Some(old);
263 new_text = Some(new);
264 d
265 } else {
266 let mut l = vec![
267 "the text to replace is not in the file as it is now:".to_owned(),
268 ];
269 l.extend(from.lines().map(|x| format!(" - {}", escape(x))));
270 l.extend(to.lines().map(|x| format!(" + {}", escape(x))));
271 l
272 }
273 }
274 (_, None) => {
275 let (from, to) = (s("old_string"), s("new_string"));
276 let mut l: Vec<String> =
277 from.lines().map(|x| format!(" - {}", escape(x))).collect();
278 l.extend(to.lines().map(|x| format!(" + {}", escape(x))));
279 l
280 }
281 },
282 };
283 (title, escape(&path), lines)
284 }
285 };
286 let (mut body, more) = head(&full);
287 if more > 0 {
288 body.push(format!("… {more} more lines (v to view all)"));
289 }
290 let sandbox = match kind {
291 "run" | "network" => match &context.sandbox {
292 Some(m) => format!(
293 "sandbox on ({m}) · network: {} · writes: worktree",
294 if kind == "network" {
295 "this call"
296 } else {
297 context.network.as_str()
298 }
299 ),
300 None => "sandbox off — runs unconfined".to_owned(),
301 },
302 "mcp" => "the MCP server runs outside speccoder's sandbox".to_owned(),
303 _ => "a file write — nothing is executed".to_owned(),
304 };
305 let scope = scope_text(kind).to_owned();
306 let task = request.task.rsplit('/').next().unwrap_or(&request.task);
307 let options = request
308 .options
309 .iter()
310 .filter_map(|o| {
311 let (key, number, label) = match o.as_str() {
312 "once" => ('y', 1, "Yes, once".to_owned()),
313 "task" => ('t', 2, format!("Yes, for the rest of {task}: {scope}")),
314 "run" => ('r', 3, format!("Yes, for this run: {scope}, in any task")),
315 "reject" => ('n', 4, "No, and tell the model why …".to_owned()),
316 "stop" => (
317 's',
318 5,
319 "Stop this task here (it waits; decide later)".to_owned(),
320 ),
321 _ => return None,
322 };
323 Some(Choice {
324 choice: o.clone(),
325 key,
326 number,
327 label,
328 })
329 })
330 .collect();
331 Presentation {
332 request: request.clone(),
333 title: title.to_owned(),
334 subject,
335 body,
336 full,
337 sandbox,
338 options,
339 scope,
340 old_text,
341 new_text,
342 file: context
343 .root
344 .as_ref()
345 .filter(|_| !path.is_empty() && matches!(kind, "write" | "edit"))
346 .map(|r| r.join(&path).display().to_string()),
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use serde_json::json;
354 use std::path::Path;
355
356 fn request(tool: &str, arguments: serde_json::Value, kind: &str, risk: &str) -> Request {
357 Request {
358 task: "001-slugify/T004".to_owned(),
359 task_title: "Handle Unicode input".to_owned(),
360 turn: 8,
361 tool: tool.to_owned(),
362 arguments,
363 kind: kind.to_owned(),
364 reason: "why".to_owned(),
365 covers: "c".to_owned(),
366 risk: risk.to_owned(),
367 claim: None,
368 options: crate::approvals::options(risk, true),
369 }
370 }
371
372 fn ctx(root: &Path) -> PresentContext {
373 PresentContext {
374 root: Some(root.to_path_buf()),
375 sandbox: Some("Seatbelt".to_owned()),
376 network: "none".to_owned(),
377 file_text: None,
378 }
379 }
380
381 #[test]
382 fn a_new_file_shows_its_head() {
383 let d = tempfile::tempdir().unwrap();
384 let content: String = (1..=25).map(|i| format!("line {i}\n")).collect();
385 let p = present(
386 &request(
387 "write_file",
388 json!({ "path": "src/new.rs", "content": content }),
389 "write",
390 "low",
391 ),
392 &ctx(d.path()),
393 );
394 assert_eq!(
395 (p.title.as_str(), p.subject.as_str()),
396 ("Write this file?", "src/new.rs")
397 );
398 assert_eq!(p.body[0], " 1 │ line 1");
399 assert_eq!(p.body.last().unwrap(), "… 5 more lines (v to view all)");
400 assert_eq!(p.full.len(), 25);
401 assert_eq!(p.sandbox, "a file write — nothing is executed");
402 assert!(p.old_text.is_none() && p.new_text.is_some());
403 }
404
405 #[test]
406 fn an_edit_is_a_numbered_diff() {
407 let d = tempfile::tempdir().unwrap();
408 std::fs::write(
409 d.path().join("Cargo.toml"),
410 "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n",
411 )
412 .unwrap();
413 let p = present(
414 &request(
415 "edit_file",
416 json!({ "path": "Cargo.toml", "old_string": "serde = \"1\"\n", "new_string": "serde = \"1\"\nunicode-normalization = \"0.1\"\n" }),
417 "edit",
418 "medium",
419 ),
420 &ctx(d.path()),
421 );
422 assert_eq!(p.title, "Edit this file?");
423 assert!(
424 p.body
425 .contains(&" 6 + unicode-normalization = \"0.1\"".to_owned()),
426 "{:#?}",
427 p.body
428 );
429 assert!(
430 p.body.contains(&" 5 5 serde = \"1\"".to_owned()),
431 "{:#?}",
432 p.body
433 );
434 let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect();
435 assert_eq!(
436 labels[1],
437 "Yes, for the rest of T004: this path, any content"
438 );
439 assert_eq!(
440 labels[2],
441 "Yes, for this run: this path, any content, in any task"
442 );
443 }
444
445 #[test]
446 fn a_missing_old_string_is_said_and_shown() {
447 let d = tempfile::tempdir().unwrap();
448 std::fs::write(d.path().join("a.rs"), "fn a() {}\n").unwrap();
449 let p = present(
450 &request(
451 "edit_file",
452 json!({ "path": "a.rs", "old_string": "fn b() {}", "new_string": "fn c() {}" }),
453 "edit",
454 "low",
455 ),
456 &ctx(d.path()),
457 );
458 assert_eq!(
459 p.body[0],
460 "the text to replace is not in the file as it is now:"
461 );
462 assert_eq!(
463 p.body[1..],
464 [" - fn b() {}".to_owned(), " + fn c() {}".to_owned()]
465 );
466 }
467
468 #[test]
469 fn commands_network_and_mcp() {
470 let d = tempfile::tempdir().unwrap();
471 let p = present(
472 &request(
473 "run_command",
474 json!({ "argv": ["sh", "-c", "echo 'hi' > x"] }),
475 "run",
476 "high",
477 ),
478 &ctx(d.path()),
479 );
480 assert_eq!(p.title, "Run this command?");
481 assert_eq!(
482 p.body,
483 ["$ sh -c 'echo '\\''hi'\\'' > x'", "cwd: . · network: none"]
484 );
485 assert_eq!(
486 p.sandbox,
487 "sandbox on (Seatbelt) · network: none · writes: worktree"
488 );
489 let numbers: Vec<u8> = p.options.iter().map(|o| o.number).collect();
491 assert_eq!(numbers, [1, 2, 4, 5]);
492 let p = present(
493 &request(
494 "run_command",
495 json!({ "argv": ["curl", "x"], "network": true }),
496 "network",
497 "high",
498 ),
499 &PresentContext {
500 sandbox: None,
501 ..ctx(d.path())
502 },
503 );
504 assert_eq!(p.title, "Use the network?");
505 assert_eq!(p.body[1], "cwd: . · network: this call");
506 assert_eq!(p.sandbox, "sandbox off — runs unconfined");
507 let p = present(
508 &request(
509 "mcp__gh__create_issue",
510 json!({ "title": "x" }),
511 "mcp",
512 "medium",
513 ),
514 &ctx(d.path()),
515 );
516 assert_eq!(
517 (p.title.as_str(), p.subject.as_str()),
518 ("Call MCP tool?", "mcp__gh__create_issue")
519 );
520 assert_eq!(p.sandbox, "the MCP server runs outside speccoder's sandbox");
521 assert!(
522 p.options[1]
523 .label
524 .ends_with("any call of this MCP tool, any arguments")
525 );
526 }
527
528 #[test]
529 fn model_text_cannot_drive_the_terminal() {
530 assert_eq!(escape("a\x1b[2Jb\x07c\nd"), "a␛[2Jb^Gc\nd");
531 let d = tempfile::tempdir().unwrap();
532 std::fs::write(d.path().join("bin"), [0u8, 159, 146, 150]).unwrap();
533 let p = present(
534 &request(
535 "write_file",
536 json!({ "path": "bin", "content": "x" }),
537 "edit",
538 "low",
539 ),
540 &ctx(d.path()),
541 );
542 assert_eq!(p.body, ["a binary file — not shown"]);
543 }
544}