1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10
11use super::Request;
12use crate::tools::policy::Object;
13
14pub const HEAD: usize = 20;
16
17const MAX_BYTES: usize = 1024 * 1024;
19
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct PresentContext {
23 pub root: Option<PathBuf>,
25 pub sandbox: Option<String>,
27 pub network: String,
29 pub file_text: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Choice {
36 pub choice: String,
38 pub key: char,
40 pub number: u8,
42 pub label: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Presentation {
49 pub request: Request,
51 pub title: String,
54 pub subject: String,
56 pub object: Vec<String>,
61 pub sources: Vec<String>,
64 pub notice: Option<String>,
66 pub body: Vec<String>,
68 pub full: Vec<String>,
70 pub sandbox: String,
72 pub options: Vec<Choice>,
74 pub scope: String,
76 pub old_text: Option<String>,
78 pub new_text: Option<String>,
80 pub file: Option<String>,
82}
83
84#[must_use]
87pub fn escape(text: &str) -> String {
88 text.chars()
89 .map(|c| match c {
90 '\x1b' => "␛".to_owned(),
91 '\n' | '\t' => c.to_string(),
92 c if (c as u32) < 0x20 => format!("^{}", char::from(b'@' + c as u8)),
93 '\x7f' => "^?".to_owned(),
94 c => c.to_string(),
95 })
96 .collect()
97}
98
99fn plain(a: &str) -> bool {
101 !a.is_empty()
102 && a.chars()
103 .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%".contains(c))
104}
105
106#[must_use]
108pub fn shell_quote(argv: &[String]) -> String {
109 argv.iter()
110 .map(|a| {
111 if plain(a) {
112 a.clone()
113 } else {
114 format!("'{}'", a.replace('\'', "'\\''"))
115 }
116 })
117 .collect::<Vec<_>>()
118 .join(" ")
119}
120
121#[must_use]
124pub fn scope_text(kind: &str) -> &'static str {
125 match kind {
126 "run" => "exactly this command in this directory",
127 "network" => "exactly this command in this directory, with the network",
128 "mcp" => "this tool as defined now, any arguments",
129 "agent" => "exactly this call again",
130 "reads" => "the agent's plain reads of one file each, inside the worktree",
131 _ => "this file, any content",
132 }
133}
134
135fn first12(hex: &str) -> &str {
136 hex.get(..12).unwrap_or(hex)
137}
138
139#[must_use]
141pub fn object_lines(object: Option<&Object>, changed: Option<&str>) -> Vec<String> {
142 let mut out = Vec::new();
143 match object {
144 Some(Object::Path { canonical, asked }) => {
145 out.push(format!("file {}", escape(canonical)));
146 if asked != canonical {
147 out.push(format!(
148 "asked as {} (resolves to the file above)",
149 escape(asked)
150 ));
151 }
152 }
153 Some(Object::Command {
154 argv,
155 argv_sha256,
156 cwd,
157 executable,
158 network,
159 }) => {
160 let json = serde_json::to_string(argv).unwrap_or_default();
161 out.push(format!(
162 "argv {} #{}",
163 escape(&json),
164 first12(argv_sha256)
165 ));
166 out.push(format!("runs {} in {}", escape(executable), escape(cwd)));
167 if *network {
168 out.push("network yes".to_owned());
169 }
170 }
171 Some(Object::Tool { name, digest }) => {
172 out.push(format!("tool {} @{}", escape(name), first12(digest)));
173 }
174 Some(Object::Agent {
175 agent,
176 call,
177 argvs,
178 paths,
179 ..
180 }) => {
181 out.push(format!(
182 "{} itself ({}), outside speccoder's sandbox",
183 escape(agent),
184 escape(call)
185 ));
186 for a in argvs {
187 out.push(format!("runs {}", escape(&shell_quote(a))));
188 }
189 for p in paths {
190 out.push(format!("file {}", escape(p)));
191 }
192 }
193 None => {}
194 }
195 if let Some(c) = changed {
196 out.push(format!("changed since approved: {}", escape(c)));
197 }
198 out
199}
200
201#[must_use]
203pub fn source_lines(request: &Request) -> Vec<String> {
204 let mut out: Vec<String> = request
205 .sources
206 .iter()
207 .map(|s| {
208 let when = if s.turn == 0 {
209 "the attempt's prompt".to_owned()
210 } else {
211 format!("turn {}", s.turn)
212 };
213 format!(
214 "contains text read from {} ({when}): \"{}\"",
215 escape(&s.from),
216 escape(&s.argument)
217 )
218 })
219 .collect();
220 if request.more > 0 {
221 out.push(format!("and {} more", request.more));
222 }
223 out
224}
225
226#[must_use]
228pub fn budget_notice(request: &Request) -> Option<String> {
229 (request.budget > 0 && request.asked == request.budget && !request.batched).then(|| {
230 format!(
231 "This is prompt {0} of {0} for this run; further calls will wait in Needs You.",
232 request.budget
233 )
234 })
235}
236
237fn head(lines: &[String]) -> (Vec<String>, usize) {
238 let shown = lines.iter().take(HEAD).cloned().collect();
239 (shown, lines.len().saturating_sub(HEAD))
240}
241
242fn numbered(text: &str) -> Vec<String> {
244 text.lines()
245 .enumerate()
246 .map(|(i, l)| format!("{:>5} │ {}", i + 1, escape(l)))
247 .collect()
248}
249
250#[must_use]
252pub fn diff(old: &str, new: &str) -> Vec<String> {
253 let d = similar::TextDiff::from_lines(old, new);
254 let mut out = Vec::new();
255 for (i, group) in d.grouped_ops(3).iter().enumerate() {
256 if i > 0 {
257 out.push(" ┈".to_owned());
258 }
259 for op in group {
260 for change in d.iter_changes(op) {
261 let (sign, old_no, new_no) = match change.tag() {
262 similar::ChangeTag::Equal => (' ', change.old_index(), change.new_index()),
263 similar::ChangeTag::Delete => ('-', change.old_index(), None),
264 similar::ChangeTag::Insert => ('+', None, change.new_index()),
265 };
266 let no = |n: Option<usize>| n.map_or(String::new(), |n| (n + 1).to_string());
267 let line = format!(
268 "{:>5} {:>5} {sign} {}",
269 no(old_no),
270 no(new_no),
271 escape(change.value().trim_end_matches(['\n', '\r']))
272 );
273 out.push(line.trim_end().to_owned());
274 }
275 }
276 }
277 out
278}
279
280fn read(context: &PresentContext, path: &str) -> Result<Option<String>, String> {
282 if let Some(text) = &context.file_text {
283 return Ok(Some(text.clone()));
284 }
285 let Some(root) = context.root.as_deref() else {
286 return Ok(None);
287 };
288 let file = root.join(path);
289 let Ok(bytes) = std::fs::read(&file) else {
290 return Ok(None);
291 };
292 if bytes.len() > MAX_BYTES {
293 return Err(format!("{} bytes — too large to show", bytes.len()));
294 }
295 String::from_utf8(bytes)
296 .map(Some)
297 .map_err(|_| "a binary file — not shown".to_owned())
298}
299
300#[must_use]
302pub fn present(request: &Request, context: &PresentContext) -> Presentation {
303 let args = &request.arguments;
304 let s = |k: &str| args[k].as_str().unwrap_or_default().to_owned();
305 let path = s("path");
306 let kind = request.kind.as_str();
307 let mut old_text = None;
308 let mut new_text = None;
309 let (title, subject, full) = match kind {
310 "run" | "network" => {
311 let argv: Vec<String> = args["argv"]
312 .as_array()
313 .into_iter()
314 .flatten()
315 .filter_map(|v| v.as_str().map(str::to_owned))
316 .collect();
317 let cmd = escape(&shell_quote(&argv));
318 let cwd = args["cwd"]
319 .as_str()
320 .filter(|c| !c.is_empty())
321 .unwrap_or(".");
322 let net = if kind == "network" {
323 "this call".to_owned()
324 } else {
325 context.network.clone()
326 };
327 (
328 if kind == "network" {
329 "Use the network?"
330 } else {
331 "Run this command?"
332 },
333 cmd.clone(),
334 vec![format!("$ {cmd}"), format!("cwd: {cwd} · network: {net}")],
335 )
336 }
337 "mcp" => {
338 let pretty = serde_json::to_string_pretty(args).unwrap_or_default();
339 (
340 "Call MCP tool?",
341 request.tool.clone(),
342 pretty.lines().map(escape).collect(),
343 )
344 }
345 "agent" | "reads" => {
348 let pretty = serde_json::to_string_pretty(&args["rawInput"]).unwrap_or_default();
349 let mut lines = vec![format!(
350 "kind: {}",
351 escape(args["kind"].as_str().unwrap_or("not said"))
352 )];
353 lines.extend(pretty.lines().map(escape));
354 (
355 "Let the agent make this call?",
356 escape(args["title"].as_str().unwrap_or("an untitled call")),
357 lines,
358 )
359 }
360 _ => {
361 let current = read(context, &path);
362 let title = if kind == "write" && matches!(current, Ok(None)) {
364 "Write this file?"
365 } else {
366 "Edit this file?"
367 };
368 let lines = match current {
369 Err(note) => vec![note],
370 Ok(current) => match (request.tool.as_str(), current) {
371 ("write_file", None) => {
372 let content = s("content");
373 new_text = Some(content.clone());
374 numbered(&content)
375 }
376 ("write_file", Some(old)) => {
377 let content = s("content");
378 let d = diff(&old, &content);
379 old_text = Some(old);
380 new_text = Some(content);
381 d
382 }
383 (_, Some(old)) => {
384 let (from, to) = (s("old_string"), s("new_string"));
385 let all = args["replace_all"] == true;
386 if !from.is_empty() && old.contains(&from) {
387 let new = if all {
388 old.replace(&from, &to)
389 } else {
390 old.replacen(&from, &to, 1)
391 };
392 let d = diff(&old, &new);
393 old_text = Some(old);
394 new_text = Some(new);
395 d
396 } else {
397 let mut l = vec![
398 "the text to replace is not in the file as it is now:".to_owned(),
399 ];
400 l.extend(from.lines().map(|x| format!(" - {}", escape(x))));
401 l.extend(to.lines().map(|x| format!(" + {}", escape(x))));
402 l
403 }
404 }
405 (_, None) => {
406 let (from, to) = (s("old_string"), s("new_string"));
407 let mut l: Vec<String> =
408 from.lines().map(|x| format!(" - {}", escape(x))).collect();
409 l.extend(to.lines().map(|x| format!(" + {}", escape(x))));
410 l
411 }
412 },
413 };
414 (title, escape(&path), lines)
415 }
416 };
417 let (mut body, more) = head(&full);
418 if more > 0 {
419 body.push(format!("… {more} more lines (v to view all)"));
420 }
421 let sandbox = match kind {
422 "run" | "network" => match &context.sandbox {
423 Some(m) => format!(
424 "sandbox on ({m}) · network: {} · writes: worktree",
425 if kind == "network" {
426 "this call"
427 } else {
428 context.network.as_str()
429 }
430 ),
431 None => "sandbox off — runs unconfined".to_owned(),
432 },
433 "mcp" => "the MCP server runs outside speccoder's sandbox".to_owned(),
434 "agent" | "reads" => {
435 "the agent's own call — it runs outside speccoder's sandbox".to_owned()
436 }
437 _ => "a file write — nothing is executed".to_owned(),
438 };
439 let scope = scope_text(kind).to_owned();
440 let task = request.task.rsplit('/').next().unwrap_or(&request.task);
441 let options = request
442 .options
443 .iter()
444 .filter_map(|o| choice(o, task, &scope))
445 .collect();
446 Presentation {
447 request: request.clone(),
448 title: title.to_owned(),
449 subject,
450 object: object_lines(request.object.as_ref(), request.changed.as_deref()),
451 sources: source_lines(request),
452 notice: budget_notice(request),
453 body,
454 full,
455 sandbox,
456 options,
457 scope,
458 old_text,
459 new_text,
460 file: context
461 .root
462 .as_ref()
463 .filter(|_| !path.is_empty() && matches!(kind, "write" | "edit"))
464 .map(|r| r.join(&path).display().to_string()),
465 }
466}
467
468#[must_use]
472pub fn confirm_text(r: &super::ConfirmRequest) -> String {
473 let mut out = format!(
474 "Bug {} — {}: {} reproduction(s) fail on the untouched code by an assertion.\n",
475 r.bug,
476 r.title,
477 r.reproductions
478 .iter()
479 .filter(|x| x.statement.starts_with('E'))
480 .count()
481 );
482 for x in &r.reproductions {
483 out.push_str(&format!(
484 "\n{} — {}\n {} :: {}\n $ {}\n",
485 x.statement,
486 x.words,
487 x.file,
488 x.test,
489 x.argv.join(" ")
490 ));
491 for l in x.excerpt.lines().take(8) {
492 out.push_str(&format!(" | {l}\n"));
493 }
494 }
495 if let Some(w) = &r.warning {
496 out.push_str(&format!("\n! {w}\n"));
497 }
498 out.push_str(&format!("\ndigest {}\n", r.digest));
499 out
500}
501
502#[must_use]
505pub fn choice(o: &str, task: &str, scope: &str) -> Option<Choice> {
506 let (key, number, label) = match o {
507 "once" => ('y', 1, "Yes, once".to_owned()),
508 "task" => ('t', 2, format!("Yes, for the rest of {task}: {scope}")),
509 "run" => ('r', 3, format!("Yes, for this run: {scope}, in any task")),
510 "always" => (
511 'a',
512 4,
513 "Always allow exactly this command (adds it to speccoder.toml)".to_owned(),
514 ),
515 "reject" => ('n', 5, "No, and tell the model why …".to_owned()),
516 "stop" => (
517 's',
518 6,
519 "Stop this task here (it waits; decide later)".to_owned(),
520 ),
521 _ => return None,
522 };
523 Some(Choice {
524 choice: o.to_owned(),
525 key,
526 number,
527 label,
528 })
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use serde_json::json;
535 use std::path::Path;
536
537 fn request(tool: &str, arguments: serde_json::Value, kind: &str, risk: &str) -> Request {
538 Request {
539 task: "001-slugify/T004".to_owned(),
540 task_title: "Handle Unicode input".to_owned(),
541 turn: 8,
542 tool: tool.to_owned(),
543 arguments,
544 kind: kind.to_owned(),
545 reason: "why".to_owned(),
546 risk: risk.to_owned(),
547 options: crate::approvals::options(risk, true, false, kind, risk),
548 ..Request::default()
549 }
550 }
551
552 fn ctx(root: &Path) -> PresentContext {
553 PresentContext {
554 root: Some(root.to_path_buf()),
555 sandbox: Some("Seatbelt".to_owned()),
556 network: "none".to_owned(),
557 file_text: None,
558 }
559 }
560
561 #[test]
562 fn a_new_file_shows_its_head() {
563 let d = tempfile::tempdir().unwrap();
564 let content: String = (1..=25).map(|i| format!("line {i}\n")).collect();
565 let p = present(
566 &request(
567 "write_file",
568 json!({ "path": "src/new.rs", "content": content }),
569 "write",
570 "low",
571 ),
572 &ctx(d.path()),
573 );
574 assert_eq!(
575 (p.title.as_str(), p.subject.as_str()),
576 ("Write this file?", "src/new.rs")
577 );
578 assert_eq!(p.body[0], " 1 │ line 1");
579 assert_eq!(p.body.last().unwrap(), "… 5 more lines (v to view all)");
580 assert_eq!(p.full.len(), 25);
581 assert_eq!(p.sandbox, "a file write — nothing is executed");
582 assert!(p.old_text.is_none() && p.new_text.is_some());
583 }
584
585 #[test]
586 fn an_edit_is_a_numbered_diff() {
587 let d = tempfile::tempdir().unwrap();
588 std::fs::write(
589 d.path().join("Cargo.toml"),
590 "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n",
591 )
592 .unwrap();
593 let p = present(
594 &request(
595 "edit_file",
596 json!({ "path": "Cargo.toml", "old_string": "serde = \"1\"\n", "new_string": "serde = \"1\"\nunicode-normalization = \"0.1\"\n" }),
597 "edit",
598 "medium",
599 ),
600 &ctx(d.path()),
601 );
602 assert_eq!(p.title, "Edit this file?");
603 assert!(
604 p.body
605 .contains(&" 6 + unicode-normalization = \"0.1\"".to_owned()),
606 "{:#?}",
607 p.body
608 );
609 assert!(
610 p.body.contains(&" 5 5 serde = \"1\"".to_owned()),
611 "{:#?}",
612 p.body
613 );
614 let labels: Vec<&str> = p.options.iter().map(|o| o.label.as_str()).collect();
615 assert_eq!(
616 labels[1],
617 "Yes, for the rest of T004: this file, any content"
618 );
619 assert_eq!(
620 labels[2],
621 "Yes, for this run: this file, any content, in any task"
622 );
623 }
624
625 #[test]
626 fn a_missing_old_string_is_said_and_shown() {
627 let d = tempfile::tempdir().unwrap();
628 std::fs::write(d.path().join("a.rs"), "fn a() {}\n").unwrap();
629 let p = present(
630 &request(
631 "edit_file",
632 json!({ "path": "a.rs", "old_string": "fn b() {}", "new_string": "fn c() {}" }),
633 "edit",
634 "low",
635 ),
636 &ctx(d.path()),
637 );
638 assert_eq!(
639 p.body[0],
640 "the text to replace is not in the file as it is now:"
641 );
642 assert_eq!(
643 p.body[1..],
644 [" - fn b() {}".to_owned(), " + fn c() {}".to_owned()]
645 );
646 }
647
648 #[test]
649 fn commands_network_and_mcp() {
650 let d = tempfile::tempdir().unwrap();
651 let p = present(
652 &request(
653 "run_command",
654 json!({ "argv": ["sh", "-c", "echo 'hi' > x"] }),
655 "run",
656 "high",
657 ),
658 &ctx(d.path()),
659 );
660 assert_eq!(p.title, "Run this command?");
661 assert_eq!(
662 p.body,
663 ["$ sh -c 'echo '\\''hi'\\'' > x'", "cwd: . · network: none"]
664 );
665 assert_eq!(
666 p.sandbox,
667 "sandbox on (Seatbelt) · network: none · writes: worktree"
668 );
669 let numbers: Vec<u8> = p.options.iter().map(|o| o.number).collect();
671 assert_eq!(numbers, [1, 2, 5, 6]);
672 let p = present(
673 &request(
674 "run_command",
675 json!({ "argv": ["curl", "x"], "network": true }),
676 "network",
677 "high",
678 ),
679 &PresentContext {
680 sandbox: None,
681 ..ctx(d.path())
682 },
683 );
684 assert_eq!(p.title, "Use the network?");
685 assert_eq!(p.body[1], "cwd: . · network: this call");
686 assert_eq!(p.sandbox, "sandbox off — runs unconfined");
687 let p = present(
688 &request(
689 "mcp__gh__create_issue",
690 json!({ "title": "x" }),
691 "mcp",
692 "medium",
693 ),
694 &ctx(d.path()),
695 );
696 assert_eq!(
697 (p.title.as_str(), p.subject.as_str()),
698 ("Call MCP tool?", "mcp__gh__create_issue")
699 );
700 assert_eq!(p.sandbox, "the MCP server runs outside speccoder's sandbox");
701 assert!(
702 p.options[1]
703 .label
704 .ends_with("this tool as defined now, any arguments")
705 );
706 }
707
708 #[test]
709 fn model_text_cannot_drive_the_terminal() {
710 assert_eq!(escape("a\x1b[2Jb\x07c\nd"), "a␛[2Jb^Gc\nd");
711 let d = tempfile::tempdir().unwrap();
712 std::fs::write(d.path().join("bin"), [0u8, 159, 146, 150]).unwrap();
713 let p = present(
714 &request(
715 "write_file",
716 json!({ "path": "bin", "content": "x" }),
717 "edit",
718 "low",
719 ),
720 &ctx(d.path()),
721 );
722 assert_eq!(p.body, ["a binary file — not shown"]);
723 }
724
725 #[test]
726 fn the_object_sources_and_budget_are_shown() {
727 let d = tempfile::tempdir().unwrap();
728 let mut r = request(
729 "run_command",
730 json!({ "argv": ["make", "check"] }),
731 "run",
732 "low",
733 );
734 r.object = Some(Object::command(
735 vec!["make".to_owned(), "check".to_owned()],
736 ".".to_owned(),
737 "/w/bin/make".to_owned(),
738 false,
739 ));
740 r.changed = Some("executable /usr/bin/make → /w/bin/make".to_owned());
741 r.sources = vec![crate::approvals::Source {
742 argument: "make check".to_owned(),
743 from: "README.md".to_owned(),
744 turn: 2,
745 }];
746 r.more = 2;
747 r.asked = 12;
748 r.budget = 12;
749 r.options = crate::approvals::options("low", true, false, "run", "low");
750 let p = present(&r, &ctx(d.path()));
751 assert_eq!(
752 p.object[0],
753 format!(
754 "argv [\"make\",\"check\"] #{}",
755 &crate::tools::policy::argv_sha256(&["make".to_owned(), "check".to_owned()])[..12]
756 )
757 );
758 assert_eq!(p.object[1], "runs /w/bin/make in .");
759 assert_eq!(
760 p.object[2],
761 "changed since approved: executable /usr/bin/make → /w/bin/make"
762 );
763 assert_eq!(
764 p.sources,
765 [
766 "contains text read from README.md (turn 2): \"make check\"",
767 "and 2 more"
768 ]
769 );
770 assert!(
771 p.notice
772 .as_deref()
773 .is_some_and(|n| n.starts_with("This is prompt 12 of 12"))
774 );
775 let keys: Vec<char> = p.options.iter().map(|o| o.key).collect();
776 assert_eq!(keys, ['y', 't', 'r', 'a', 'n', 's']);
777 let mut w = request(
779 "write_file",
780 json!({ "path": "src/a/x.rs", "content": "" }),
781 "write",
782 "low",
783 );
784 w.object = Some(Object::Path {
785 canonical: "src/b/x.rs".to_owned(),
786 asked: "src/a/x.rs".to_owned(),
787 });
788 let p = present(&w, &ctx(d.path()));
789 assert_eq!(
790 p.object,
791 [
792 "file src/b/x.rs",
793 "asked as src/a/x.rs (resolves to the file above)"
794 ]
795 );
796 }
797}