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 pub(crate) fn new() -> Self {
35 Self {
36 gist: None,
37 href: None,
38 notes: Vec::new(),
39 body: None,
40 }
41 }
42
43 pub(crate) fn body(mut self, body: Markup) -> Self {
44 self.body = Some(body);
45 self
46 }
47
48 pub(crate) 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 pub(crate) fn note(mut self, note: impl Into<String>) -> Self {
59 self.notes.push(note.into());
60 self
61 }
62
63 pub(crate) 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 pub(crate) 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
133pub(crate) fn prose(scribe: &Scribe, source: &str) -> Markup {
136 html! { div .tool.tool--prose { (scribe.markdown(source)) } }
137}
138
139pub(crate) fn printed(scribe: &Scribe, source: &str) -> Markup {
142 html! { div .tool.tool--prose { (scribe.markdown_printed(source)) } }
143}
144
145fn flag(input: &Value, field: &str) -> bool {
146 input.get(field).and_then(Value::as_bool).unwrap_or(false)
147}
148
149fn bash(scribe: &Scribe, input: &Value) -> Option<Setting> {
150 let command = text(input, "command")?;
151 Some(
152 Setting::new()
153 .gist(text(input, "description").unwrap_or_else(|| first_line(command)))
154 .maybe_note(flag(input, "run_in_background").then_some("background"))
155 .maybe_note(input.get("timeout").and_then(Value::as_u64).map(duration))
156 .maybe_note(flag(input, "dangerouslyDisableSandbox").then_some("sandbox off"))
157 .body(scribe.code_block("bash", command)),
158 )
159}
160
161fn read(input: &Value) -> Option<Setting> {
164 let path = text(input, "file_path")?;
165 let offset = input.get("offset").and_then(Value::as_u64);
166 let limit = input.get("limit").and_then(Value::as_u64);
167 Some(Setting::new().gist(path).maybe_note(span(offset, limit)))
168}
169
170fn span(offset: Option<u64>, limit: Option<u64>) -> Option<String> {
175 match (offset, limit) {
176 (Some(offset), Some(limit)) => {
177 let last = offset.checked_add(limit.checked_sub(1)?)?;
178 Some(format!("lines {offset}–{last}"))
179 }
180 (Some(offset), None) => Some(format!("from line {offset}")),
181 (None, Some(0)) => None,
182 (None, Some(limit)) => Some(format!("first {limit} lines")),
183 (None, None) => None,
184 }
185}
186
187fn write(scribe: &Scribe, input: &Value) -> Option<Setting> {
188 let path = text(input, "file_path")?;
189 let content = text(input, "content")?;
190 Some(
191 Setting::new()
192 .gist(path)
193 .body(scribe.code_block(lang_for_path(path), content)),
194 )
195}
196
197fn edit(scribe: &Scribe, input: &Value) -> Option<Setting> {
198 let old = text(input, "old_string")?;
199 let new = text(input, "new_string")?;
200 Some(
201 Setting::new()
202 .maybe_gist(text(input, "file_path"))
203 .maybe_note(flag(input, "replace_all").then_some("replace all"))
204 .body(scribe.code_block("diff", &unified_diff(old, new))),
205 )
206}
207
208fn todos(input: &Value) -> Option<Setting> {
211 let todos = input.get("todos")?.as_array()?;
212 let active = todos
213 .iter()
214 .find(|todo| text(todo, "status") == Some("in_progress"))
215 .and_then(|todo| text(todo, "content"));
216 Some(Setting::new().maybe_gist(active).body(html! {
217 ul .tool.tool--todos {
218 @for todo in todos {
219 @let content = text(todo, "content").unwrap_or("");
220 @let status = text(todo, "status").unwrap_or("pending");
221 li .tool__todo data-status=(status) { (content) }
222 }
223 }
224 }))
225}
226
227fn agent(scribe: &Scribe, input: &Value) -> Option<Setting> {
228 let prompt = text(input, "prompt")?;
229 Some(
230 Setting::new()
231 .maybe_gist(text(input, "description"))
232 .maybe_note(text(input, "subagent_type"))
233 .maybe_note(text(input, "model"))
234 .maybe_note(text(input, "isolation"))
235 .maybe_note(flag(input, "run_in_background").then_some("background"))
236 .body(prose(scribe, prompt)),
237 )
238}
239
240fn skill(scribe: &Scribe, input: &Value) -> Option<Setting> {
241 let skill = text(input, "skill")?;
242 let setting = Setting::new().gist(skill);
243 Some(match text(input, "args") {
244 Some(args) => setting.body(prose(scribe, args)),
245 None => setting,
246 })
247}
248
249fn tool_search(input: &Value) -> Option<Setting> {
250 let query = text(input, "query")?;
251 Some(
252 Setting::new().gist(query).maybe_note(
253 input
254 .get("max_results")
255 .and_then(Value::as_u64)
256 .map(|most| format!("at most {most}")),
257 ),
258 )
259}
260
261fn web_search(input: &Value) -> Option<Setting> {
262 Some(Setting::new().gist(text(input, "query")?))
263}
264
265fn web_fetch(scribe: &Scribe, input: &Value) -> Option<Setting> {
266 let url = text(input, "url")?;
267 let prompt = text(input, "prompt")?;
268 Some(
269 Setting::new()
270 .gist(url)
271 .href(url)
272 .body(prose(scribe, prompt)),
273 )
274}
275
276fn task_write(scribe: &Scribe, input: &Value) -> Option<Setting> {
277 let gist = match text(input, "subject") {
279 Some(subject) => subject.to_owned(),
280 None => format!("task {}", text(input, "taskId")?),
281 };
282 let setting = Setting::new().gist(gist).maybe_note(text(input, "status"));
283 Some(match text(input, "description") {
284 Some(description) => setting.body(prose(scribe, description)),
285 None => setting,
286 })
287}
288
289fn task_reference(input: &Value) -> Option<Setting> {
290 let id = text(input, "taskId").or_else(|| text(input, "task_id"))?;
291 Some(
292 Setting::new()
293 .gist(id)
294 .maybe_note(flag(input, "block").then_some("waits"))
295 .maybe_note(input.get("timeout").and_then(Value::as_u64).map(duration)),
296 )
297}
298
299fn questions(scribe: &Scribe, input: &Value) -> Option<Setting> {
300 let asked = input.get("questions")?.as_array()?;
301 let first = asked
302 .first()
303 .and_then(|question| text(question, "question"));
304 Some(
305 Setting::new()
306 .maybe_gist(first)
307 .maybe_note(
308 asked
309 .iter()
310 .any(|question| flag(question, "multiSelect"))
311 .then_some("multi-select"),
312 )
313 .body(html! {
314 div .tool.tool--questions {
315 @for question in asked {
316 section .tool__question {
317 p .tool__ask {
318 @if let Some(header) = text(question, "header") {
319 span .tool__header { (header) }
320 }
321 (text(question, "question").unwrap_or(""))
322 }
323 @if let Some(options) = question.get("options").and_then(Value::as_array) {
324 ul .tool__options {
325 @for option in options {
326 li .tool__option {
327 span .tool__label { (text(option, "label").unwrap_or("")) }
328 @if let Some(description) = text(option, "description") {
329 span .tool__description { (description) }
330 }
331 @if let Some(preview) = text(option, "preview") {
337 (scribe.code_block("text", preview))
338 }
339 }
340 }
341 }
342 }
343 }
344 }
345 }
346 }),
347 )
348}
349
350fn plan(scribe: &Scribe, input: &Value) -> Option<Setting> {
354 let plan = text(input, "plan")?;
355 let allowed = input
356 .get("allowedPrompts")
357 .and_then(Value::as_array)
358 .filter(|prompts| !prompts.is_empty());
359 Some(Setting::new().maybe_gist(heading(plan)).body(html! {
360 (prose(scribe, plan))
361 @if let Some(allowed) = allowed {
362 ul .tool.tool--prompts {
363 @for prompt in allowed {
364 li .tool__prompt {
365 span .tool__label { (text(prompt, "tool").unwrap_or("")) }
366 (text(prompt, "prompt").unwrap_or(""))
367 }
368 }
369 }
370 }
371 }))
372}
373
374fn workflow(scribe: &Scribe, input: &Value) -> Option<Setting> {
375 let script = text(input, "script");
376 let path = text(input, "scriptPath");
377 let name = text(input, "name");
378 let gist = name
379 .or(path)
380 .map(str::to_owned)
381 .or_else(|| script.and_then(workflow_name))?;
382 let setting = Setting::new()
383 .gist(gist)
384 .maybe_note(text(input, "resumeFromRunId").map(|_| "resumed"));
385 Some(match (script, input.get("args")) {
386 (Some(script), _) => setting.body(scribe.code_block("javascript", script)),
387 (None, Some(args)) => setting.body(json(args)),
388 (None, None) => setting,
389 })
390}
391
392fn workflow_name(script: &str) -> Option<String> {
395 let (_, rest) = script.split_once("name:")?;
396 let rest = rest.trim_start();
397 let quote = rest.chars().next().filter(|c| *c == '\'' || *c == '"')?;
398 let (name, _) = rest[quote.len_utf8()..].split_once(quote)?;
399 Some(name.to_owned())
400}
401
402fn message(scribe: &Scribe, input: &Value) -> Option<Setting> {
405 let body = text(input, "message").or_else(|| text(input, "content"))?;
406 Some(
407 Setting::new()
408 .maybe_gist(text(input, "summary"))
409 .maybe_note(text(input, "to").or_else(|| text(input, "recipient")))
410 .body(prose(scribe, body)),
411 )
412}
413
414fn findings(input: &Value) -> Option<Setting> {
415 let found = input.get("findings")?.as_array()?;
416 Some(
417 Setting::new()
418 .gist(match found.len() {
419 1 => "1 finding".to_owned(),
420 count => format!("{count} findings"),
421 })
422 .maybe_note(text(input, "level"))
423 .body(html! {
424 ul .tool.tool--findings {
425 @for finding in found {
426 li .tool__finding {
427 p .tool__where {
428 span .tool__label { (text(finding, "file").unwrap_or("")) }
429 @if let Some(line) = finding.get("line").and_then(Value::as_u64) {
430 span .tool__line { ":" (line) }
431 }
432 @if let Some(category) = text(finding, "category") {
433 span .tool__header { (category) }
434 }
435 @if let Some(verdict) = text(finding, "verdict") {
436 span .tool__header { (verdict) }
437 }
438 }
439 p .tool__summary { (text(finding, "summary").unwrap_or("")) }
440 @if let Some(scenario) = text(finding, "failure_scenario") {
441 p .tool__scenario { (scenario) }
442 }
443 }
444 }
445 }
446 }),
447 )
448}
449
450fn shed_error_tag(text: &str) -> &str {
452 text.trim()
453 .strip_prefix("<tool_use_error>")
454 .and_then(|text| text.strip_suffix("</tool_use_error>"))
455 .unwrap_or(text)
456}
457
458pub fn hint(
484 answering: Option<&Answered>,
485 content: &ToolResultContent,
486 is_error: bool,
487) -> Option<String> {
488 const PAST_THE_COLUMN: usize = 160;
489
490 let text = match spoken(content) {
491 Ok(text) => text,
492 Err(blocks) => Cow::Owned(
498 references(answering.map(|answered| answered.tool.as_str()), blocks)?.join(", "),
499 ),
500 };
501 let shown = if is_error {
502 Cow::Borrowed(shed_error_tag(text.as_ref()))
505 } else {
506 match answering.map(|answered| answered.tool.as_str()) {
507 Some("Read") => Cow::Owned(unnumbered(text.as_ref())),
510 Some("AskUserQuestion") => match answers(text.as_ref()) {
515 Some(answered) => Cow::Owned(answered.first()?.chosen.to_string()),
516 None => text,
517 },
518 Some("TaskOutput") => match tags(text.as_ref()).first() {
521 Some((_, value)) => Cow::Owned((*value).to_owned()),
522 None => text,
523 },
524 _ => text,
527 }
528 };
529 let line = shown.lines().find_map(|line| {
530 let written: String = ansi_runs(line).into_iter().map(|(_, run)| run).collect();
531 (!written.trim().is_empty()).then(|| written.trim().to_owned())
532 })?;
533 let Some((cut, _)) = line.char_indices().nth(PAST_THE_COLUMN) else {
534 return Some(line);
535 };
536 Some(format!("{}…", line[..cut].trim_end()))
537}
538
539pub fn result(
543 scribe: &Scribe,
544 answers: Option<&Answered>,
545 content: &ToolResultContent,
546 is_error: bool,
547) -> Markup {
548 let tool = answers.map(|answered| answered.tool.as_str());
549 let text = match spoken(content) {
550 Ok(text) => text,
551 Err(blocks) => return blocks_result(scribe, tool, blocks),
552 };
553 let text = text.as_ref();
554 if is_error {
555 return failure(text);
556 }
557 match tool {
558 Some("Read") => source(scribe, answers.and_then(|a| a.subject.as_deref()), text),
559 Some("WebSearch") => web_results(scribe, text),
560 Some("TaskOutput") => task_output(scribe, text),
561 Some("AskUserQuestion") => chosen(scribe, text),
562 Some("Agent" | "ExitPlanMode" | "Skill" | "WebFetch") => prose(scribe, text),
564 _ => plain(scribe, text),
565 }
566}
567
568const ACKNOWLEDGEMENTS: [(&str, &str, &str); 9] = [
577 ("Write", "File created successfully at:", ""),
578 ("Write", "The file", "has been updated successfully."),
579 ("Edit", "The file", "has been updated successfully."),
580 (
581 "Edit",
582 "The file",
583 "All occurrences were successfully replaced.",
584 ),
585 ("TodoWrite", "Todos have been modified successfully.", ""),
586 ("TaskUpdate", "Updated task #", ""),
587 ("Skill", "Launching skill:", ""),
588 ("Agent", "Async agent launched successfully.", ""),
592 ("EnterPlanMode", "Entered plan mode.", ""),
593];
594
595const FILE_STATE_NOTE: &str = "(file state is current in your context — no need to Read it back)";
598
599pub fn acknowledges(tool: &str, text: &str) -> bool {
603 let text = text
604 .trim()
605 .strip_suffix(FILE_STATE_NOTE)
606 .unwrap_or(text)
607 .trim();
608 ACKNOWLEDGEMENTS
609 .iter()
610 .filter(|(named, _, _)| *named == tool)
611 .any(|(_, opening, closing)| text.starts_with(opening) && text.ends_with(closing))
612}
613
614const ANSWER_OPENINGS: [&str; 2] = ["Your questions have been answered: ", "The user answered: "];
617
618const ANSWER_CLOSINGS: [&str; 2] = [
619 ". You can now continue with these answers in mind.",
620 " You can now continue with these answers in mind.",
621];
622
623const SELECTED_PREVIEW: &str = "\" selected preview:";
625
626const TYPED_OPENING: &str = "(no option selected) notes:";
629
630struct Answer<'a> {
633 question: &'a str,
634 chosen: &'a str,
635 typed: bool,
636}
637
638fn answers(text: &str) -> Option<Vec<Answer<'_>>> {
649 let text = text.trim();
650 let mut body = ANSWER_OPENINGS
651 .iter()
652 .find_map(|opening| text.strip_prefix(opening))?;
653 body = ANSWER_CLOSINGS
654 .iter()
655 .find_map(|closing| body.strip_suffix(closing))
656 .unwrap_or(body);
657 if !body.starts_with('"') {
658 return None;
659 }
660
661 let anchors: Vec<usize> = body.match_indices("\"=").map(|(at, _)| at).collect();
662 let mut pairs = Vec::with_capacity(anchors.len());
663 let mut cursor = 1;
665 for (index, &anchor) in anchors.iter().enumerate() {
666 let question = body.get(cursor..anchor)?;
667 let mut start = anchor + 2;
668 let quoted = body.get(start..)?.starts_with('"');
671 if quoted {
672 start += 1;
673 }
674 let (region, next) = match anchors.get(index + 1) {
675 Some(&following) => {
676 let opens = body.get(start..following)?.rfind(", \"")? + start;
677 (body.get(start..opens)?, opens + 3)
678 }
679 None => (body.get(start..)?, body.len()),
680 };
681 let answer = match region.find(SELECTED_PREVIEW) {
684 Some(echo) => region.get(..echo)?,
685 None if quoted => region.strip_suffix('"').unwrap_or(region),
686 None => region,
687 };
688 let answer = answer.trim();
689 pairs.push(Answer {
690 question: question.trim(),
691 chosen: answer.strip_prefix(TYPED_OPENING).unwrap_or(answer).trim(),
692 typed: !quoted,
693 });
694 cursor = next;
695 }
696 (!pairs.is_empty()).then_some(pairs)
697}
698
699fn chosen(scribe: &Scribe, text: &str) -> Markup {
703 let Some(answered) = answers(text) else {
704 return plain(scribe, text);
705 };
706 html! {
707 ul .tool.tool--answers {
708 @for answer in answered {
709 li .tool__answer {
710 p .tool__ask { (answer.question) }
711 p .tool__chosen data-typed[answer.typed] { (answer.chosen) }
714 }
715 }
716 }
717 }
718}
719
720fn failure(text: &str) -> Markup {
723 terminal(shed_error_tag(text))
726}
727
728pub(crate) fn plain(scribe: &Scribe, text: &str) -> Markup {
732 match serde_json::from_str::<Value>(text.trim()) {
733 Ok(value) if value.is_object() || value.is_array() => scribe.code_block(
734 "json",
735 &serde_json::to_string_pretty(&value).unwrap_or_else(|_| text.to_owned()),
736 ),
737 _ => terminal(text),
738 }
739}
740
741const COLOURS: [&str; 8] = [
746 "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
747];
748
749const BRIGHT_COLOURS: [&str; 8] = [
750 "bright-black",
751 "bright-red",
752 "bright-green",
753 "bright-yellow",
754 "bright-blue",
755 "bright-magenta",
756 "bright-cyan",
757 "bright-white",
758];
759
760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
765enum Pigment {
766 Named(&'static str),
767 Exact(u8, u8, u8),
768}
769
770impl Pigment {
771 fn hex(self) -> Option<String> {
772 match self {
773 Pigment::Named(_) => None,
774 Pigment::Exact(red, green, blue) => Some(format!("#{red:02x}{green:02x}{blue:02x}")),
775 }
776 }
777
778 fn name(self) -> Option<&'static str> {
779 match self {
780 Pigment::Named(name) => Some(name),
781 Pigment::Exact(..) => None,
782 }
783 }
784}
785
786#[derive(Clone, Copy, Default, PartialEq, Eq)]
791struct Ink {
792 foreground: Option<Pigment>,
793 background: Option<Pigment>,
794 bold: bool,
795 dim: bool,
796 italic: bool,
797 underline: bool,
798}
799
800impl Ink {
801 fn classes(self) -> Option<String> {
804 let mut classes = Vec::new();
805 if let Some(name) = self.foreground.and_then(Pigment::name) {
806 classes.push(format!("ansi--{name}"));
807 }
808 if let Some(name) = self.background.and_then(Pigment::name) {
809 classes.push(format!("ansi--bg-{name}"));
810 }
811 for (set, name) in [
812 (self.bold, "bold"),
813 (self.dim, "dim"),
814 (self.italic, "italic"),
815 (self.underline, "underline"),
816 ] {
817 if set {
818 classes.push(format!("ansi--{name}"));
819 }
820 }
821 (!classes.is_empty()).then(|| format!("ansi {}", classes.join(" ")))
822 }
823
824 fn style(self) -> Option<String> {
826 let mut declarations = Vec::new();
827 if let Some(hex) = self.foreground.and_then(Pigment::hex) {
828 declarations.push(format!("color:{hex}"));
829 }
830 if let Some(hex) = self.background.and_then(Pigment::hex) {
831 declarations.push(format!("background-color:{hex}"));
832 }
833 (!declarations.is_empty()).then(|| declarations.join(";"))
834 }
835
836 fn apply(&mut self, params: &str) {
838 let mut codes = params
840 .split(';')
841 .map(|param| param.trim().parse::<u16>().unwrap_or(0));
842 while let Some(code) = codes.next() {
843 match code {
844 0 => *self = Self::default(),
845 1 => self.bold = true,
846 2 => self.dim = true,
847 3 => self.italic = true,
848 4 => self.underline = true,
849 22 => (self.bold, self.dim) = (false, false),
850 23 => self.italic = false,
851 24 => self.underline = false,
852 30..=37 => self.foreground = Some(Pigment::Named(COLOURS[code as usize - 30])),
853 38 => self.foreground = extended(&mut codes),
854 39 => self.foreground = None,
855 40..=47 => self.background = Some(Pigment::Named(COLOURS[code as usize - 40])),
856 48 => self.background = extended(&mut codes),
857 49 => self.background = None,
858 90..=97 => {
859 self.foreground = Some(Pigment::Named(BRIGHT_COLOURS[code as usize - 90]));
860 }
861 100..=107 => {
862 self.background = Some(Pigment::Named(BRIGHT_COLOURS[code as usize - 100]));
863 }
864 _ => {}
865 }
866 }
867 }
868}
869
870fn extended(codes: &mut impl Iterator<Item = u16>) -> Option<Pigment> {
874 match codes.next()? {
875 5 => Some(indexed(u8::try_from(codes.next()?).ok()?)),
876 2 => {
877 let mut channel = || u8::try_from(codes.next()?).ok();
878 Some(Pigment::Exact(channel()?, channel()?, channel()?))
879 }
880 _ => None,
881 }
882}
883
884fn indexed(index: u8) -> Pigment {
888 match index {
889 0..=7 => Pigment::Named(COLOURS[index as usize]),
890 8..=15 => Pigment::Named(BRIGHT_COLOURS[index as usize - 8]),
891 16..=231 => {
892 let cube = index - 16;
893 let level = |step: u8| if step == 0 { 0 } else { 55 + step * 40 };
896 Pigment::Exact(level(cube / 36), level((cube / 6) % 6), level(cube % 6))
897 }
898 _ => {
899 let grey = 8 + (index - 232) * 10;
900 Pigment::Exact(grey, grey, grey)
901 }
902 }
903}
904
905fn last_frame(text: &str) -> Cow<'_, str> {
923 if !text.contains('\r') {
924 return Cow::Borrowed(text);
925 }
926 let text = text.replace("\r\n", "\n");
927 let kept: Vec<&str> = text
928 .split('\n')
929 .map(|line| line.rsplit('\r').next().unwrap_or(line))
930 .collect();
931 Cow::Owned(kept.join("\n"))
932}
933
934fn terminal(text: &str) -> Markup {
935 let text = last_frame(text);
936 html! {
937 pre { code {
938 @for (ink, run) in ansi_runs(&text) {
939 @let classes = ink.classes();
940 @let style = ink.style();
941 @if classes.is_some() || style.is_some() {
942 span class=[classes] style=[style] { (run) }
943 } @else {
944 (run)
945 }
946 }
947 } }
948 }
949}
950
951fn ansi_runs(text: &str) -> Vec<(Ink, &str)> {
956 let mut runs = Vec::new();
957 let mut ink = Ink::default();
958 let mut rest = text;
959 while let Some(escape) = rest.find('\u{1b}') {
960 let (written, from_escape) = rest.split_at(escape);
961 if !written.is_empty() {
962 runs.push((ink, written));
963 }
964 match control_sequence(from_escape) {
965 Some((params, 'm', remainder)) => {
966 ink.apply(params);
967 rest = remainder;
968 }
969 Some((_, _, remainder)) => rest = remainder,
970 None => rest = &from_escape[1..],
972 }
973 }
974 if !rest.is_empty() {
975 runs.push((ink, rest));
976 }
977 runs
978}
979
980fn control_sequence(text: &str) -> Option<(&str, char, &str)> {
983 let params = text.strip_prefix("\u{1b}[")?;
984 let end = params.find(|byte: char| ('\u{40}'..='\u{7e}').contains(&byte))?;
987 let (params, rest) = params.split_at(end);
988 let mut characters = rest.chars();
989 let final_byte = characters.next()?;
990 Some((params, final_byte, characters.as_str()))
991}
992
993fn source(scribe: &Scribe, path: Option<&str>, text: &str) -> Markup {
997 let lang = path.map_or("", lang_for_path);
998 scribe.code_block(lang, &unnumbered(text))
999}
1000
1001fn unnumbered(text: &str) -> String {
1004 let stripped: Option<Vec<&str>> = text.lines().map(strip_line_number).collect();
1005 stripped.map_or_else(|| text.to_owned(), |lines| lines.join("\n"))
1006}
1007
1008fn strip_line_number(line: &str) -> Option<&str> {
1011 if line.trim().is_empty() {
1012 return Some(line);
1013 }
1014 let (number, rest) = line.trim_start().split_once('\t')?;
1015 number.parse::<u64>().ok().map(|_| rest)
1016}
1017
1018fn web_results(scribe: &Scribe, text: &str) -> Markup {
1021 let Some((before, rest)) = text.split_once("Links: ") else {
1022 return plain(scribe, text);
1023 };
1024 let (links, after) = rest.split_once('\n').unwrap_or((rest, ""));
1025 let Ok(Value::Array(links)) = serde_json::from_str::<Value>(links.trim()) else {
1026 return plain(scribe, text);
1027 };
1028 html! {
1029 div .tool.tool--results {
1030 p .tool__query { (before.trim()) }
1031 ul .tool__links {
1032 @for link in &links {
1033 @if let Some(url) = text_of(link, "url") {
1034 li { a href=(url) { (text_of(link, "title").unwrap_or(url)) } }
1035 }
1036 }
1037 }
1038 (prose(scribe, after.trim()))
1039 }
1040 }
1041}
1042
1043fn text_of<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
1044 value.get(field)?.as_str()
1045}
1046
1047fn task_output(scribe: &Scribe, text: &str) -> Markup {
1050 let tagged: Vec<(&str, &str)> = tags(text);
1051 if tagged.is_empty() {
1052 return plain(scribe, text);
1053 }
1054 html! {
1055 dl .tool.tool--facts {
1056 @for (name, value) in tagged.iter().filter(|(name, _)| *name != "output") {
1057 dt { (name) }
1058 dd { (value) }
1059 }
1060 }
1061 @for (_, output) in tagged.iter().filter(|(name, _)| *name == "output") {
1062 (prose(scribe, output))
1063 }
1064 }
1065}
1066
1067fn tags(text: &str) -> Vec<(&str, &str)> {
1069 let mut tagged = Vec::new();
1070 let mut rest = text;
1071 while let Some((_, after)) = rest.split_once('<') {
1072 let Some((name, after)) = after.split_once('>') else {
1073 break;
1074 };
1075 let close = format!("</{name}>");
1076 let Some((value, after)) = after.split_once(close.as_str()) else {
1077 rest = after;
1078 continue;
1079 };
1080 tagged.push((name, value.trim()));
1081 rest = after;
1082 }
1083 tagged
1084}
1085
1086pub fn spoken(content: &ToolResultContent) -> Result<Cow<'_, str>, &[Block]> {
1092 match content {
1093 ToolResultContent::Text(text) => Ok(Cow::Borrowed(text)),
1094 ToolResultContent::Blocks(blocks) => spoken_blocks(blocks).map(Cow::Owned).ok_or(blocks),
1095 }
1096}
1097
1098fn spoken_blocks(blocks: &[Block]) -> Option<String> {
1101 blocks
1102 .iter()
1103 .map(|block| match block {
1104 Block::Known(Known::Text { text }) => Some(text.as_str()),
1105 _ => None,
1106 })
1107 .collect::<Option<Vec<&str>>>()
1108 .filter(|spoken| !spoken.is_empty())
1109 .map(|spoken| spoken.join("\n"))
1110}
1111
1112fn references<'a>(tool: Option<&str>, blocks: &'a [Block]) -> Option<Vec<&'a str>> {
1116 if tool != Some("ToolSearch") {
1117 return None;
1118 }
1119 let names: Vec<&str> = blocks
1120 .iter()
1121 .filter_map(|block| match block {
1122 Block::Unknown(value) => text_of(value, "tool_name"),
1123 Block::Known(_) => None,
1124 })
1125 .collect();
1126 (!names.is_empty() && names.len() == blocks.len()).then_some(names)
1127}
1128
1129fn blocks_result(scribe: &Scribe, tool: Option<&str>, blocks: &[Block]) -> Markup {
1133 match references(tool, blocks) {
1134 Some(names) => html! {
1135 ul .tool.tool--references {
1136 @for name in names { li .tool__reference { (name) } }
1137 }
1138 },
1139 None => html! { @for block in blocks { (scribe.block(block, false)) } },
1140 }
1141}
1142
1143fn lang_for_path(path: &str) -> &str {
1147 Path::new(path)
1148 .extension()
1149 .and_then(|extension| extension.to_str())
1150 .unwrap_or("")
1151}
1152
1153fn unified_diff(old: &str, new: &str) -> String {
1157 let mut diff = String::new();
1158 for line in old.lines() {
1159 diff.push('-');
1160 diff.push_str(line);
1161 diff.push('\n');
1162 }
1163 for line in new.lines() {
1164 diff.push('+');
1165 diff.push_str(line);
1166 diff.push('\n');
1167 }
1168 diff
1169}
1170
1171fn first_line(text: &str) -> &str {
1172 text.lines().next().unwrap_or(text)
1173}
1174
1175fn heading(document: &str) -> Option<&str> {
1177 document
1178 .lines()
1179 .find_map(|line| line.strip_prefix("# "))
1180 .map(str::trim)
1181}
1182
1183fn duration(milliseconds: u64) -> String {
1186 let seconds = milliseconds / 1_000;
1187 match seconds {
1188 0..60 => format!("{seconds}s"),
1189 60..3_600 => format!("{}m", seconds / 60),
1190 _ => format!("{}h", seconds / 3_600),
1191 }
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 #[test]
1199 fn a_read_names_the_lines_it_asked_for() {
1200 assert_eq!(span(Some(105), Some(4)).as_deref(), Some("lines 105–108"));
1201 assert_eq!(span(Some(105), None).as_deref(), Some("from line 105"));
1202 assert_eq!(span(None, Some(40)).as_deref(), Some("first 40 lines"));
1203 assert_eq!(span(None, None), None);
1204 }
1205
1206 #[test]
1207 fn a_read_that_asked_for_no_span_names_none() {
1208 assert_eq!(span(Some(105), Some(0)), None);
1212 assert_eq!(span(None, Some(0)), None);
1213 assert_eq!(span(Some(u64::MAX), Some(4)), None);
1214 }
1215
1216 #[test]
1217 fn a_listing_loses_the_harness_numbering() {
1218 let listing = " 1\tuse std::fs;\n 2\t\n 3\tfn main() {}";
1219
1220 assert_eq!(unnumbered(listing), "use std::fs;\n\nfn main() {}");
1221 }
1222
1223 #[test]
1224 fn a_file_whose_own_lines_start_with_numbers_keeps_them() {
1225 let csv = "1\t2\t3\nyear\tcount";
1228
1229 assert_eq!(unnumbered(csv), csv);
1230 }
1231
1232 #[test]
1233 fn timeouts_read_in_the_units_they_were_set_in() {
1234 assert_eq!(duration(45_000), "45s");
1235 assert_eq!(duration(600_000), "10m");
1236 assert_eq!(duration(7_200_000), "2h");
1237 }
1238
1239 #[test]
1240 fn a_workflow_is_named_by_the_meta_block_when_nothing_else_names_it() {
1241 let script = "export const meta = {\n name: 'centre-the-bestiary',\n}\n";
1242
1243 assert_eq!(
1244 workflow_name(script).as_deref(),
1245 Some("centre-the-bestiary")
1246 );
1247 assert_eq!(workflow_name("const x = 1"), None);
1248 }
1249
1250 #[test]
1251 fn a_task_answer_parses_into_its_tagged_facts() {
1252 let answer = "<status>completed</status>\n\n<output>\nAll done.\n</output>";
1253
1254 assert_eq!(
1255 tags(answer),
1256 [("status", "completed"), ("output", "All done.")]
1257 );
1258 }
1259
1260 #[test]
1261 fn a_result_saying_only_that_the_call_worked_is_an_acknowledgement() {
1262 assert!(acknowledges(
1263 "Edit",
1264 "The file /src/render.rs has been updated successfully. \
1265 (file state is current in your context — no need to Read it back)"
1266 ));
1267 assert!(acknowledges(
1268 "Write",
1269 "File created successfully at: /src/tools.rs"
1270 ));
1271 assert!(acknowledges(
1272 "TaskUpdate",
1273 "Updated task #3 subject, status"
1274 ));
1275 }
1276
1277 #[test]
1278 fn a_result_carrying_more_than_the_acknowledgement_is_not_one() {
1279 assert!(!acknowledges(
1282 "Edit",
1283 "The file /src/render.rs has been updated successfully. \
1284 (note: the file had been modified on disk since you last read it)"
1285 ));
1286 assert!(!acknowledges("Bash", "Updated task #3 status"));
1289 }
1290
1291 fn inks(text: &str) -> Vec<(Option<String>, &str)> {
1292 ansi_runs(text)
1293 .into_iter()
1294 .map(|(ink, run)| (ink.classes(), run))
1295 .collect()
1296 }
1297
1298 #[test]
1299 fn colour_holds_until_the_terminal_changes_it() {
1300 assert_eq!(
1301 inks("plain \u{1b}[32mgreen \u{1b}[1mand bold\u{1b}[0m back"),
1302 [
1303 (None, "plain "),
1304 (Some("ansi ansi--green".to_owned()), "green "),
1305 (Some("ansi ansi--green ansi--bold".to_owned()), "and bold"),
1306 (None, " back"),
1307 ]
1308 );
1309 }
1310
1311 #[test]
1312 fn a_sequence_that_drives_the_terminal_leaves_nothing_behind() {
1313 assert_eq!(
1316 inks("\u{1b}[2Kone\u{1b}[Htwo\u{1b}three"),
1317 [(None, "one"), (None, "two"), (None, "three")]
1318 );
1319 }
1320
1321 #[test]
1322 fn the_sixteen_named_colours_stay_the_folios_to_grind() {
1323 assert_eq!(indexed(1), Pigment::Named("red"));
1326 assert_eq!(indexed(9), Pigment::Named("bright-red"));
1327 assert_eq!(Pigment::Named("red").hex(), None);
1328 }
1329
1330 #[test]
1331 fn a_colour_stated_outright_is_carried_as_its_own_value() {
1332 let mut ink = Ink::default();
1333
1334 ink.apply("38;5;208");
1337 assert_eq!(ink.style().as_deref(), Some("color:#ff8700"));
1338 ink.apply("38;5;244");
1339 assert_eq!(ink.style().as_deref(), Some("color:#808080"));
1340 ink.apply("48;2;120;90;200");
1341 assert_eq!(
1342 ink.style().as_deref(),
1343 Some("color:#808080;background-color:#785ac8")
1344 );
1345 }
1346
1347 #[test]
1348 fn an_extended_colour_does_not_swallow_the_codes_after_it() {
1349 let mut ink = Ink::default();
1350
1351 ink.apply("38;5;208;1;4");
1352
1353 assert_eq!(ink.style().as_deref(), Some("color:#ff8700"));
1354 assert_eq!(
1355 ink.classes().as_deref(),
1356 Some("ansi ansi--bold ansi--underline")
1357 );
1358 }
1359
1360 #[test]
1361 fn a_plan_is_titled_by_its_leading_heading() {
1362 assert_eq!(
1363 heading("# Centre the bestiary\n\nBody."),
1364 Some("Centre the bestiary")
1365 );
1366 assert_eq!(heading("No heading here."), None);
1367 }
1368}