1use std::{borrow::Cow, path::Path};
11
12use maud::{Markup, html};
13use serde_json::Value;
14
15use crate::{
16 render::{Scribe, json},
17 transcript::{Answered, Block, Known, ToolResultContent},
18};
19
20pub struct Setting {
24 pub gist: Option<String>,
25 pub href: Option<String>,
27 pub notes: Vec<String>,
30 pub body: Option<Markup>,
31}
32
33impl Setting {
34 fn new() -> Self {
35 Self {
36 gist: None,
37 href: None,
38 notes: Vec::new(),
39 body: None,
40 }
41 }
42
43 fn body(mut self, body: Markup) -> Self {
44 self.body = Some(body);
45 self
46 }
47
48 fn gist(mut self, gist: impl Into<String>) -> Self {
49 self.gist = Some(gist.into());
50 self
51 }
52
53 fn href(mut self, href: impl Into<String>) -> Self {
54 self.href = Some(href.into());
55 self
56 }
57
58 fn note(mut self, note: impl Into<String>) -> Self {
59 self.notes.push(note.into());
60 self
61 }
62
63 fn maybe_gist(self, gist: Option<impl Into<String>>) -> Self {
64 match gist {
65 Some(gist) => self.gist(gist),
66 None => self,
67 }
68 }
69
70 fn maybe_note(self, note: Option<impl Into<String>>) -> Self {
71 match note {
72 Some(note) => self.note(note),
73 None => self,
74 }
75 }
76}
77
78pub fn call(scribe: &Scribe, name: &str, input: &Value) -> Setting {
81 view(scribe, name, input)
82 .unwrap_or_else(|| Setting::new().maybe_gist(subject(input)).body(json(input)))
83}
84
85fn view(scribe: &Scribe, name: &str, input: &Value) -> Option<Setting> {
86 match name {
87 "Bash" => bash(scribe, input),
88 "Read" => read(input),
89 "Write" => write(scribe, input),
90 "Edit" => edit(scribe, input),
91 "TodoWrite" => todos(input),
92 "Agent" => agent(scribe, input),
93 "Skill" => skill(scribe, input),
94 "ToolSearch" => tool_search(input),
95 "WebSearch" => web_search(input),
96 "WebFetch" => web_fetch(scribe, input),
97 "TaskCreate" | "TaskUpdate" => task_write(scribe, input),
98 "TaskGet" | "TaskOutput" | "TaskStop" => task_reference(input),
99 "AskUserQuestion" => questions(scribe, input),
100 "EnterPlanMode" => Some(Setting::new()),
101 "ExitPlanMode" => plan(scribe, input),
102 "Workflow" => workflow(scribe, input),
103 "SendMessage" => message(scribe, input),
104 "ReportFindings" => findings(input),
105 _ => None,
106 }
107}
108
109fn subject(input: &Value) -> Option<&str> {
115 [
116 "description",
117 "command",
118 "file_path",
119 "pattern",
120 "path",
121 "url",
122 "query",
123 "prompt",
124 ]
125 .iter()
126 .find_map(|field| text(input, field))
127}
128
129fn text<'a>(input: &'a Value, field: &str) -> Option<&'a str> {
130 input.get(field)?.as_str()
131}
132
133fn prose(scribe: &Scribe, source: &str) -> Markup {
136 html! { div .tool.tool--prose { (scribe.markdown(source)) } }
137}
138
139fn flag(input: &Value, field: &str) -> bool {
140 input.get(field).and_then(Value::as_bool).unwrap_or(false)
141}
142
143fn bash(scribe: &Scribe, input: &Value) -> Option<Setting> {
144 let command = text(input, "command")?;
145 Some(
146 Setting::new()
147 .gist(text(input, "description").unwrap_or_else(|| first_line(command)))
148 .maybe_note(flag(input, "run_in_background").then_some("background"))
149 .maybe_note(input.get("timeout").and_then(Value::as_u64).map(duration))
150 .maybe_note(flag(input, "dangerouslyDisableSandbox").then_some("sandbox off"))
151 .body(scribe.code_block("bash", command)),
152 )
153}
154
155fn read(input: &Value) -> Option<Setting> {
158 let path = text(input, "file_path")?;
159 let offset = input.get("offset").and_then(Value::as_u64);
160 let limit = input.get("limit").and_then(Value::as_u64);
161 Some(Setting::new().gist(path).maybe_note(span(offset, limit)))
162}
163
164fn span(offset: Option<u64>, limit: Option<u64>) -> Option<String> {
169 match (offset, limit) {
170 (Some(offset), Some(limit)) => {
171 let last = offset.checked_add(limit.checked_sub(1)?)?;
172 Some(format!("lines {offset}–{last}"))
173 }
174 (Some(offset), None) => Some(format!("from line {offset}")),
175 (None, Some(0)) => None,
176 (None, Some(limit)) => Some(format!("first {limit} lines")),
177 (None, None) => None,
178 }
179}
180
181fn write(scribe: &Scribe, input: &Value) -> Option<Setting> {
182 let path = text(input, "file_path")?;
183 let content = text(input, "content")?;
184 Some(
185 Setting::new()
186 .gist(path)
187 .body(scribe.code_block(lang_for_path(path), content)),
188 )
189}
190
191fn edit(scribe: &Scribe, input: &Value) -> Option<Setting> {
192 let old = text(input, "old_string")?;
193 let new = text(input, "new_string")?;
194 Some(
195 Setting::new()
196 .maybe_gist(text(input, "file_path"))
197 .maybe_note(flag(input, "replace_all").then_some("replace all"))
198 .body(scribe.code_block("diff", &unified_diff(old, new))),
199 )
200}
201
202fn todos(input: &Value) -> Option<Setting> {
205 let todos = input.get("todos")?.as_array()?;
206 let active = todos
207 .iter()
208 .find(|todo| text(todo, "status") == Some("in_progress"))
209 .and_then(|todo| text(todo, "content"));
210 Some(Setting::new().maybe_gist(active).body(html! {
211 ul .tool.tool--todos {
212 @for todo in todos {
213 @let content = text(todo, "content").unwrap_or("");
214 @let status = text(todo, "status").unwrap_or("pending");
215 li .tool__todo data-status=(status) { (content) }
216 }
217 }
218 }))
219}
220
221fn agent(scribe: &Scribe, input: &Value) -> Option<Setting> {
222 let prompt = text(input, "prompt")?;
223 Some(
224 Setting::new()
225 .maybe_gist(text(input, "description"))
226 .maybe_note(text(input, "subagent_type"))
227 .maybe_note(text(input, "model"))
228 .maybe_note(text(input, "isolation"))
229 .maybe_note(flag(input, "run_in_background").then_some("background"))
230 .body(prose(scribe, prompt)),
231 )
232}
233
234fn skill(scribe: &Scribe, input: &Value) -> Option<Setting> {
235 let skill = text(input, "skill")?;
236 let setting = Setting::new().gist(skill);
237 Some(match text(input, "args") {
238 Some(args) => setting.body(prose(scribe, args)),
239 None => setting,
240 })
241}
242
243fn tool_search(input: &Value) -> Option<Setting> {
244 let query = text(input, "query")?;
245 Some(
246 Setting::new().gist(query).maybe_note(
247 input
248 .get("max_results")
249 .and_then(Value::as_u64)
250 .map(|most| format!("at most {most}")),
251 ),
252 )
253}
254
255fn web_search(input: &Value) -> Option<Setting> {
256 Some(Setting::new().gist(text(input, "query")?))
257}
258
259fn web_fetch(scribe: &Scribe, input: &Value) -> Option<Setting> {
260 let url = text(input, "url")?;
261 let prompt = text(input, "prompt")?;
262 Some(
263 Setting::new()
264 .gist(url)
265 .href(url)
266 .body(prose(scribe, prompt)),
267 )
268}
269
270fn task_write(scribe: &Scribe, input: &Value) -> Option<Setting> {
271 let gist = match text(input, "subject") {
273 Some(subject) => subject.to_owned(),
274 None => format!("task {}", text(input, "taskId")?),
275 };
276 let setting = Setting::new().gist(gist).maybe_note(text(input, "status"));
277 Some(match text(input, "description") {
278 Some(description) => setting.body(prose(scribe, description)),
279 None => setting,
280 })
281}
282
283fn task_reference(input: &Value) -> Option<Setting> {
284 let id = text(input, "taskId").or_else(|| text(input, "task_id"))?;
285 Some(
286 Setting::new()
287 .gist(id)
288 .maybe_note(flag(input, "block").then_some("waits"))
289 .maybe_note(input.get("timeout").and_then(Value::as_u64).map(duration)),
290 )
291}
292
293fn questions(scribe: &Scribe, input: &Value) -> Option<Setting> {
294 let asked = input.get("questions")?.as_array()?;
295 let first = asked
296 .first()
297 .and_then(|question| text(question, "question"));
298 Some(
299 Setting::new()
300 .maybe_gist(first)
301 .maybe_note(
302 asked
303 .iter()
304 .any(|question| flag(question, "multiSelect"))
305 .then_some("multi-select"),
306 )
307 .body(html! {
308 div .tool.tool--questions {
309 @for question in asked {
310 section .tool__question {
311 p .tool__ask {
312 @if let Some(header) = text(question, "header") {
313 span .tool__header { (header) }
314 }
315 (text(question, "question").unwrap_or(""))
316 }
317 @if let Some(options) = question.get("options").and_then(Value::as_array) {
318 ul .tool__options {
319 @for option in options {
320 li .tool__option {
321 span .tool__label { (text(option, "label").unwrap_or("")) }
322 @if let Some(description) = text(option, "description") {
323 span .tool__description { (description) }
324 }
325 @if let Some(preview) = text(option, "preview") {
331 (scribe.code_block("text", preview))
332 }
333 }
334 }
335 }
336 }
337 }
338 }
339 }
340 }),
341 )
342}
343
344fn plan(scribe: &Scribe, input: &Value) -> Option<Setting> {
348 let plan = text(input, "plan")?;
349 let allowed = input
350 .get("allowedPrompts")
351 .and_then(Value::as_array)
352 .filter(|prompts| !prompts.is_empty());
353 Some(Setting::new().maybe_gist(heading(plan)).body(html! {
354 (prose(scribe, plan))
355 @if let Some(allowed) = allowed {
356 ul .tool.tool--prompts {
357 @for prompt in allowed {
358 li .tool__prompt {
359 span .tool__label { (text(prompt, "tool").unwrap_or("")) }
360 (text(prompt, "prompt").unwrap_or(""))
361 }
362 }
363 }
364 }
365 }))
366}
367
368fn workflow(scribe: &Scribe, input: &Value) -> Option<Setting> {
369 let script = text(input, "script");
370 let path = text(input, "scriptPath");
371 let name = text(input, "name");
372 let gist = name
373 .or(path)
374 .map(str::to_owned)
375 .or_else(|| script.and_then(workflow_name))?;
376 let setting = Setting::new()
377 .gist(gist)
378 .maybe_note(text(input, "resumeFromRunId").map(|_| "resumed"));
379 Some(match (script, input.get("args")) {
380 (Some(script), _) => setting.body(scribe.code_block("javascript", script)),
381 (None, Some(args)) => setting.body(json(args)),
382 (None, None) => setting,
383 })
384}
385
386fn workflow_name(script: &str) -> Option<String> {
389 let (_, rest) = script.split_once("name:")?;
390 let rest = rest.trim_start();
391 let quote = rest.chars().next().filter(|c| *c == '\'' || *c == '"')?;
392 let (name, _) = rest[quote.len_utf8()..].split_once(quote)?;
393 Some(name.to_owned())
394}
395
396fn message(scribe: &Scribe, input: &Value) -> Option<Setting> {
399 let body = text(input, "message").or_else(|| text(input, "content"))?;
400 Some(
401 Setting::new()
402 .maybe_gist(text(input, "summary"))
403 .maybe_note(text(input, "to").or_else(|| text(input, "recipient")))
404 .body(prose(scribe, body)),
405 )
406}
407
408fn findings(input: &Value) -> Option<Setting> {
409 let found = input.get("findings")?.as_array()?;
410 Some(
411 Setting::new()
412 .gist(match found.len() {
413 1 => "1 finding".to_owned(),
414 count => format!("{count} findings"),
415 })
416 .maybe_note(text(input, "level"))
417 .body(html! {
418 ul .tool.tool--findings {
419 @for finding in found {
420 li .tool__finding {
421 p .tool__where {
422 span .tool__label { (text(finding, "file").unwrap_or("")) }
423 @if let Some(line) = finding.get("line").and_then(Value::as_u64) {
424 span .tool__line { ":" (line) }
425 }
426 @if let Some(category) = text(finding, "category") {
427 span .tool__header { (category) }
428 }
429 @if let Some(verdict) = text(finding, "verdict") {
430 span .tool__header { (verdict) }
431 }
432 }
433 p .tool__summary { (text(finding, "summary").unwrap_or("")) }
434 @if let Some(scenario) = text(finding, "failure_scenario") {
435 p .tool__scenario { (scenario) }
436 }
437 }
438 }
439 }
440 }),
441 )
442}
443
444pub fn result(
448 scribe: &Scribe,
449 answers: Option<&Answered>,
450 content: &ToolResultContent,
451 is_error: bool,
452) -> Markup {
453 let tool = answers.map(|answered| answered.tool.as_str());
454 let text = match spoken(content) {
455 Ok(text) => text,
456 Err(blocks) => return blocks_result(scribe, tool, blocks),
457 };
458 let text = text.as_ref();
459 if is_error {
460 return failure(text);
461 }
462 match tool {
463 Some("Read") => source(scribe, answers.and_then(|a| a.subject.as_deref()), text),
464 Some("WebSearch") => web_results(scribe, text),
465 Some("TaskOutput") => task_output(scribe, text),
466 Some("AskUserQuestion") => chosen(scribe, text),
467 Some("Agent" | "ExitPlanMode" | "Skill" | "WebFetch") => prose(scribe, text),
469 _ => plain(scribe, text),
470 }
471}
472
473const ACKNOWLEDGEMENTS: [(&str, &str, &str); 9] = [
482 ("Write", "File created successfully at:", ""),
483 ("Write", "The file", "has been updated successfully."),
484 ("Edit", "The file", "has been updated successfully."),
485 (
486 "Edit",
487 "The file",
488 "All occurrences were successfully replaced.",
489 ),
490 ("TodoWrite", "Todos have been modified successfully.", ""),
491 ("TaskUpdate", "Updated task #", ""),
492 ("Skill", "Launching skill:", ""),
493 ("Agent", "Async agent launched successfully.", ""),
497 ("EnterPlanMode", "Entered plan mode.", ""),
498];
499
500const FILE_STATE_NOTE: &str = "(file state is current in your context — no need to Read it back)";
503
504pub fn acknowledges(tool: &str, text: &str) -> bool {
508 let text = text
509 .trim()
510 .strip_suffix(FILE_STATE_NOTE)
511 .unwrap_or(text)
512 .trim();
513 ACKNOWLEDGEMENTS
514 .iter()
515 .filter(|(named, _, _)| *named == tool)
516 .any(|(_, opening, closing)| text.starts_with(opening) && text.ends_with(closing))
517}
518
519const ANSWER_OPENINGS: [&str; 2] = ["Your questions have been answered: ", "The user answered: "];
522
523const ANSWER_CLOSINGS: [&str; 2] = [
524 ". You can now continue with these answers in mind.",
525 " You can now continue with these answers in mind.",
526];
527
528const SELECTED_PREVIEW: &str = "\" selected preview:";
530
531const TYPED_OPENING: &str = "(no option selected) notes:";
534
535struct Answer<'a> {
538 question: &'a str,
539 chosen: &'a str,
540 typed: bool,
541}
542
543fn answers(text: &str) -> Option<Vec<Answer<'_>>> {
554 let text = text.trim();
555 let mut body = ANSWER_OPENINGS
556 .iter()
557 .find_map(|opening| text.strip_prefix(opening))?;
558 body = ANSWER_CLOSINGS
559 .iter()
560 .find_map(|closing| body.strip_suffix(closing))
561 .unwrap_or(body);
562 if !body.starts_with('"') {
563 return None;
564 }
565
566 let anchors: Vec<usize> = body.match_indices("\"=").map(|(at, _)| at).collect();
567 let mut pairs = Vec::with_capacity(anchors.len());
568 let mut cursor = 1;
570 for (index, &anchor) in anchors.iter().enumerate() {
571 let question = body.get(cursor..anchor)?;
572 let mut start = anchor + 2;
573 let quoted = body.get(start..)?.starts_with('"');
576 if quoted {
577 start += 1;
578 }
579 let (region, next) = match anchors.get(index + 1) {
580 Some(&following) => {
581 let opens = body.get(start..following)?.rfind(", \"")? + start;
582 (body.get(start..opens)?, opens + 3)
583 }
584 None => (body.get(start..)?, body.len()),
585 };
586 let answer = match region.find(SELECTED_PREVIEW) {
589 Some(echo) => region.get(..echo)?,
590 None if quoted => region.strip_suffix('"').unwrap_or(region),
591 None => region,
592 };
593 let answer = answer.trim();
594 pairs.push(Answer {
595 question: question.trim(),
596 chosen: answer.strip_prefix(TYPED_OPENING).unwrap_or(answer).trim(),
597 typed: !quoted,
598 });
599 cursor = next;
600 }
601 (!pairs.is_empty()).then_some(pairs)
602}
603
604fn chosen(scribe: &Scribe, text: &str) -> Markup {
608 let Some(answered) = answers(text) else {
609 return plain(scribe, text);
610 };
611 html! {
612 ul .tool.tool--answers {
613 @for answer in answered {
614 li .tool__answer {
615 p .tool__ask { (answer.question) }
616 p .tool__chosen data-typed[answer.typed] { (answer.chosen) }
619 }
620 }
621 }
622 }
623}
624
625fn failure(text: &str) -> Markup {
628 let text = text
629 .trim()
630 .strip_prefix("<tool_use_error>")
631 .and_then(|text| text.strip_suffix("</tool_use_error>"))
632 .unwrap_or(text);
633 terminal(text)
636}
637
638fn plain(scribe: &Scribe, text: &str) -> Markup {
642 match serde_json::from_str::<Value>(text.trim()) {
643 Ok(value) if value.is_object() || value.is_array() => scribe.code_block(
644 "json",
645 &serde_json::to_string_pretty(&value).unwrap_or_else(|_| text.to_owned()),
646 ),
647 _ => terminal(text),
648 }
649}
650
651const COLOURS: [&str; 8] = [
656 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
657];
658
659const BRIGHT_COLOURS: [&str; 8] = [
660 "bright-black",
661 "bright-red",
662 "bright-green",
663 "bright-yellow",
664 "bright-blue",
665 "bright-magenta",
666 "bright-cyan",
667 "bright-white",
668];
669
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
675enum Pigment {
676 Named(&'static str),
677 Exact(u8, u8, u8),
678}
679
680impl Pigment {
681 fn hex(self) -> Option<String> {
682 match self {
683 Pigment::Named(_) => None,
684 Pigment::Exact(red, green, blue) => Some(format!("#{red:02x}{green:02x}{blue:02x}")),
685 }
686 }
687
688 fn name(self) -> Option<&'static str> {
689 match self {
690 Pigment::Named(name) => Some(name),
691 Pigment::Exact(..) => None,
692 }
693 }
694}
695
696#[derive(Clone, Copy, Default, PartialEq, Eq)]
701struct Ink {
702 foreground: Option<Pigment>,
703 background: Option<Pigment>,
704 bold: bool,
705 dim: bool,
706 italic: bool,
707 underline: bool,
708}
709
710impl Ink {
711 fn classes(self) -> Option<String> {
714 let mut classes = Vec::new();
715 if let Some(name) = self.foreground.and_then(Pigment::name) {
716 classes.push(format!("ansi--{name}"));
717 }
718 if let Some(name) = self.background.and_then(Pigment::name) {
719 classes.push(format!("ansi--bg-{name}"));
720 }
721 for (set, name) in [
722 (self.bold, "bold"),
723 (self.dim, "dim"),
724 (self.italic, "italic"),
725 (self.underline, "underline"),
726 ] {
727 if set {
728 classes.push(format!("ansi--{name}"));
729 }
730 }
731 (!classes.is_empty()).then(|| format!("ansi {}", classes.join(" ")))
732 }
733
734 fn style(self) -> Option<String> {
736 let mut declarations = Vec::new();
737 if let Some(hex) = self.foreground.and_then(Pigment::hex) {
738 declarations.push(format!("color:{hex}"));
739 }
740 if let Some(hex) = self.background.and_then(Pigment::hex) {
741 declarations.push(format!("background-color:{hex}"));
742 }
743 (!declarations.is_empty()).then(|| declarations.join(";"))
744 }
745
746 fn apply(&mut self, params: &str) {
748 let mut codes = params
750 .split(';')
751 .map(|param| param.trim().parse::<u16>().unwrap_or(0));
752 while let Some(code) = codes.next() {
753 match code {
754 0 => *self = Self::default(),
755 1 => self.bold = true,
756 2 => self.dim = true,
757 3 => self.italic = true,
758 4 => self.underline = true,
759 22 => (self.bold, self.dim) = (false, false),
760 23 => self.italic = false,
761 24 => self.underline = false,
762 30..=37 => self.foreground = Some(Pigment::Named(COLOURS[code as usize - 30])),
763 38 => self.foreground = extended(&mut codes),
764 39 => self.foreground = None,
765 40..=47 => self.background = Some(Pigment::Named(COLOURS[code as usize - 40])),
766 48 => self.background = extended(&mut codes),
767 49 => self.background = None,
768 90..=97 => {
769 self.foreground = Some(Pigment::Named(BRIGHT_COLOURS[code as usize - 90]));
770 }
771 100..=107 => {
772 self.background = Some(Pigment::Named(BRIGHT_COLOURS[code as usize - 100]));
773 }
774 _ => {}
775 }
776 }
777 }
778}
779
780fn extended(codes: &mut impl Iterator<Item = u16>) -> Option<Pigment> {
784 match codes.next()? {
785 5 => Some(indexed(u8::try_from(codes.next()?).ok()?)),
786 2 => {
787 let mut channel = || u8::try_from(codes.next()?).ok();
788 Some(Pigment::Exact(channel()?, channel()?, channel()?))
789 }
790 _ => None,
791 }
792}
793
794fn indexed(index: u8) -> Pigment {
798 match index {
799 0..=7 => Pigment::Named(COLOURS[index as usize]),
800 8..=15 => Pigment::Named(BRIGHT_COLOURS[index as usize - 8]),
801 16..=231 => {
802 let cube = index - 16;
803 let level = |step: u8| if step == 0 { 0 } else { 55 + step * 40 };
806 Pigment::Exact(level(cube / 36), level((cube / 6) % 6), level(cube % 6))
807 }
808 _ => {
809 let grey = 8 + (index - 232) * 10;
810 Pigment::Exact(grey, grey, grey)
811 }
812 }
813}
814
815fn terminal(text: &str) -> Markup {
818 html! {
819 pre { code {
820 @for (ink, run) in ansi_runs(text) {
821 @let classes = ink.classes();
822 @let style = ink.style();
823 @if classes.is_some() || style.is_some() {
824 span class=[classes] style=[style] { (run) }
825 } @else {
826 (run)
827 }
828 }
829 } }
830 }
831}
832
833fn ansi_runs(text: &str) -> Vec<(Ink, &str)> {
838 let mut runs = Vec::new();
839 let mut ink = Ink::default();
840 let mut rest = text;
841 while let Some(escape) = rest.find('\u{1b}') {
842 let (written, from_escape) = rest.split_at(escape);
843 if !written.is_empty() {
844 runs.push((ink, written));
845 }
846 match control_sequence(from_escape) {
847 Some((params, 'm', remainder)) => {
848 ink.apply(params);
849 rest = remainder;
850 }
851 Some((_, _, remainder)) => rest = remainder,
852 None => rest = &from_escape[1..],
854 }
855 }
856 if !rest.is_empty() {
857 runs.push((ink, rest));
858 }
859 runs
860}
861
862fn control_sequence(text: &str) -> Option<(&str, char, &str)> {
865 let params = text.strip_prefix("\u{1b}[")?;
866 let end = params.find(|byte: char| ('\u{40}'..='\u{7e}').contains(&byte))?;
869 let (params, rest) = params.split_at(end);
870 let mut characters = rest.chars();
871 let final_byte = characters.next()?;
872 Some((params, final_byte, characters.as_str()))
873}
874
875fn source(scribe: &Scribe, path: Option<&str>, text: &str) -> Markup {
879 let lang = path.map_or("", lang_for_path);
880 scribe.code_block(lang, &unnumbered(text))
881}
882
883fn unnumbered(text: &str) -> String {
886 let stripped: Option<Vec<&str>> = text.lines().map(strip_line_number).collect();
887 stripped.map_or_else(|| text.to_owned(), |lines| lines.join("\n"))
888}
889
890fn strip_line_number(line: &str) -> Option<&str> {
893 if line.trim().is_empty() {
894 return Some(line);
895 }
896 let (number, rest) = line.trim_start().split_once('\t')?;
897 number.parse::<u64>().ok().map(|_| rest)
898}
899
900fn web_results(scribe: &Scribe, text: &str) -> Markup {
903 let Some((before, rest)) = text.split_once("Links: ") else {
904 return plain(scribe, text);
905 };
906 let (links, after) = rest.split_once('\n').unwrap_or((rest, ""));
907 let Ok(Value::Array(links)) = serde_json::from_str::<Value>(links.trim()) else {
908 return plain(scribe, text);
909 };
910 html! {
911 div .tool.tool--results {
912 p .tool__query { (before.trim()) }
913 ul .tool__links {
914 @for link in &links {
915 @if let Some(url) = text_of(link, "url") {
916 li { a href=(url) { (text_of(link, "title").unwrap_or(url)) } }
917 }
918 }
919 }
920 (prose(scribe, after.trim()))
921 }
922 }
923}
924
925fn text_of<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
926 value.get(field)?.as_str()
927}
928
929fn task_output(scribe: &Scribe, text: &str) -> Markup {
932 let tagged: Vec<(&str, &str)> = tags(text);
933 if tagged.is_empty() {
934 return plain(scribe, text);
935 }
936 html! {
937 dl .tool.tool--facts {
938 @for (name, value) in tagged.iter().filter(|(name, _)| *name != "output") {
939 dt { (name) }
940 dd { (value) }
941 }
942 }
943 @for (_, output) in tagged.iter().filter(|(name, _)| *name == "output") {
944 (prose(scribe, output))
945 }
946 }
947}
948
949fn tags(text: &str) -> Vec<(&str, &str)> {
951 let mut tagged = Vec::new();
952 let mut rest = text;
953 while let Some((_, after)) = rest.split_once('<') {
954 let Some((name, after)) = after.split_once('>') else {
955 break;
956 };
957 let close = format!("</{name}>");
958 let Some((value, after)) = after.split_once(close.as_str()) else {
959 rest = after;
960 continue;
961 };
962 tagged.push((name, value.trim()));
963 rest = after;
964 }
965 tagged
966}
967
968pub fn spoken(content: &ToolResultContent) -> Result<Cow<'_, str>, &[Block]> {
974 match content {
975 ToolResultContent::Text(text) => Ok(Cow::Borrowed(text)),
976 ToolResultContent::Blocks(blocks) => spoken_blocks(blocks).map(Cow::Owned).ok_or(blocks),
977 }
978}
979
980fn spoken_blocks(blocks: &[Block]) -> Option<String> {
983 blocks
984 .iter()
985 .map(|block| match block {
986 Block::Known(Known::Text { text }) => Some(text.as_str()),
987 _ => None,
988 })
989 .collect::<Option<Vec<&str>>>()
990 .filter(|spoken| !spoken.is_empty())
991 .map(|spoken| spoken.join("\n"))
992}
993
994fn blocks_result(scribe: &Scribe, tool: Option<&str>, blocks: &[Block]) -> Markup {
998 let names: Vec<&str> = blocks
999 .iter()
1000 .filter_map(|block| match block {
1001 Block::Unknown(value) => text_of(value, "tool_name"),
1002 Block::Known(_) => None,
1003 })
1004 .collect();
1005 if tool == Some("ToolSearch") && names.len() == blocks.len() && !names.is_empty() {
1006 return html! {
1007 ul .tool.tool--references {
1008 @for name in names { li .tool__reference { (name) } }
1009 }
1010 };
1011 }
1012 html! { @for block in blocks { (scribe.block(block, false)) } }
1013}
1014
1015fn lang_for_path(path: &str) -> &str {
1019 Path::new(path)
1020 .extension()
1021 .and_then(|extension| extension.to_str())
1022 .unwrap_or("")
1023}
1024
1025fn unified_diff(old: &str, new: &str) -> String {
1029 let mut diff = String::new();
1030 for line in old.lines() {
1031 diff.push('-');
1032 diff.push_str(line);
1033 diff.push('\n');
1034 }
1035 for line in new.lines() {
1036 diff.push('+');
1037 diff.push_str(line);
1038 diff.push('\n');
1039 }
1040 diff
1041}
1042
1043fn first_line(text: &str) -> &str {
1044 text.lines().next().unwrap_or(text)
1045}
1046
1047fn heading(document: &str) -> Option<&str> {
1049 document
1050 .lines()
1051 .find_map(|line| line.strip_prefix("# "))
1052 .map(str::trim)
1053}
1054
1055fn duration(milliseconds: u64) -> String {
1058 let seconds = milliseconds / 1_000;
1059 match seconds {
1060 0..60 => format!("{seconds}s"),
1061 60..3_600 => format!("{}m", seconds / 60),
1062 _ => format!("{}h", seconds / 3_600),
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use super::*;
1069
1070 #[test]
1071 fn a_read_names_the_lines_it_asked_for() {
1072 assert_eq!(span(Some(105), Some(4)).as_deref(), Some("lines 105–108"));
1073 assert_eq!(span(Some(105), None).as_deref(), Some("from line 105"));
1074 assert_eq!(span(None, Some(40)).as_deref(), Some("first 40 lines"));
1075 assert_eq!(span(None, None), None);
1076 }
1077
1078 #[test]
1079 fn a_read_that_asked_for_no_span_names_none() {
1080 assert_eq!(span(Some(105), Some(0)), None);
1084 assert_eq!(span(None, Some(0)), None);
1085 assert_eq!(span(Some(u64::MAX), Some(4)), None);
1086 }
1087
1088 #[test]
1089 fn a_listing_loses_the_harness_numbering() {
1090 let listing = " 1\tuse std::fs;\n 2\t\n 3\tfn main() {}";
1091
1092 assert_eq!(unnumbered(listing), "use std::fs;\n\nfn main() {}");
1093 }
1094
1095 #[test]
1096 fn a_file_whose_own_lines_start_with_numbers_keeps_them() {
1097 let csv = "1\t2\t3\nyear\tcount";
1100
1101 assert_eq!(unnumbered(csv), csv);
1102 }
1103
1104 #[test]
1105 fn timeouts_read_in_the_units_they_were_set_in() {
1106 assert_eq!(duration(45_000), "45s");
1107 assert_eq!(duration(600_000), "10m");
1108 assert_eq!(duration(7_200_000), "2h");
1109 }
1110
1111 #[test]
1112 fn a_workflow_is_named_by_the_meta_block_when_nothing_else_names_it() {
1113 let script = "export const meta = {\n name: 'centre-the-bestiary',\n}\n";
1114
1115 assert_eq!(
1116 workflow_name(script).as_deref(),
1117 Some("centre-the-bestiary")
1118 );
1119 assert_eq!(workflow_name("const x = 1"), None);
1120 }
1121
1122 #[test]
1123 fn a_task_answer_parses_into_its_tagged_facts() {
1124 let answer = "<status>completed</status>\n\n<output>\nAll done.\n</output>";
1125
1126 assert_eq!(
1127 tags(answer),
1128 [("status", "completed"), ("output", "All done.")]
1129 );
1130 }
1131
1132 #[test]
1133 fn a_result_saying_only_that_the_call_worked_is_an_acknowledgement() {
1134 assert!(acknowledges(
1135 "Edit",
1136 "The file /src/render.rs has been updated successfully. \
1137 (file state is current in your context — no need to Read it back)"
1138 ));
1139 assert!(acknowledges(
1140 "Write",
1141 "File created successfully at: /src/tools.rs"
1142 ));
1143 assert!(acknowledges(
1144 "TaskUpdate",
1145 "Updated task #3 subject, status"
1146 ));
1147 }
1148
1149 #[test]
1150 fn a_result_carrying_more_than_the_acknowledgement_is_not_one() {
1151 assert!(!acknowledges(
1154 "Edit",
1155 "The file /src/render.rs has been updated successfully. \
1156 (note: the file had been modified on disk since you last read it)"
1157 ));
1158 assert!(!acknowledges("Bash", "Updated task #3 status"));
1161 }
1162
1163 fn inks(text: &str) -> Vec<(Option<String>, &str)> {
1164 ansi_runs(text)
1165 .into_iter()
1166 .map(|(ink, run)| (ink.classes(), run))
1167 .collect()
1168 }
1169
1170 #[test]
1171 fn colour_holds_until_the_terminal_changes_it() {
1172 assert_eq!(
1173 inks("plain \u{1b}[32mgreen \u{1b}[1mand bold\u{1b}[0m back"),
1174 [
1175 (None, "plain "),
1176 (Some("ansi ansi--green".to_owned()), "green "),
1177 (Some("ansi ansi--green ansi--bold".to_owned()), "and bold"),
1178 (None, " back"),
1179 ]
1180 );
1181 }
1182
1183 #[test]
1184 fn a_sequence_that_drives_the_terminal_leaves_nothing_behind() {
1185 assert_eq!(
1188 inks("\u{1b}[2Kone\u{1b}[Htwo\u{1b}three"),
1189 [(None, "one"), (None, "two"), (None, "three")]
1190 );
1191 }
1192
1193 #[test]
1194 fn the_sixteen_named_colours_stay_the_folios_to_grind() {
1195 assert_eq!(indexed(1), Pigment::Named("red"));
1198 assert_eq!(indexed(9), Pigment::Named("bright-red"));
1199 assert_eq!(Pigment::Named("red").hex(), None);
1200 }
1201
1202 #[test]
1203 fn a_colour_stated_outright_is_carried_as_its_own_value() {
1204 let mut ink = Ink::default();
1205
1206 ink.apply("38;5;208");
1209 assert_eq!(ink.style().as_deref(), Some("color:#ff8700"));
1210 ink.apply("38;5;244");
1211 assert_eq!(ink.style().as_deref(), Some("color:#808080"));
1212 ink.apply("48;2;120;90;200");
1213 assert_eq!(
1214 ink.style().as_deref(),
1215 Some("color:#808080;background-color:#785ac8")
1216 );
1217 }
1218
1219 #[test]
1220 fn an_extended_colour_does_not_swallow_the_codes_after_it() {
1221 let mut ink = Ink::default();
1222
1223 ink.apply("38;5;208;1;4");
1224
1225 assert_eq!(ink.style().as_deref(), Some("color:#ff8700"));
1226 assert_eq!(
1227 ink.classes().as_deref(),
1228 Some("ansi ansi--bold ansi--underline")
1229 );
1230 }
1231
1232 #[test]
1233 fn a_plan_is_titled_by_its_leading_heading() {
1234 assert_eq!(
1235 heading("# Centre the bestiary\n\nBody."),
1236 Some("Centre the bestiary")
1237 );
1238 assert_eq!(heading("No heading here."), None);
1239 }
1240}