Skip to main content

claude_scriptorium/
tools.rs

1//! How each built-in tool's call and result are set.
2//!
3//! A call's summary line carries the labelling and its body carries the
4//! subject, so the views here decide two things per tool: what the line says,
5//! and what shape the subject takes (a shell command, a diff, a plan, a list of
6//! questions). The shapes are the harness's rather than a contract, so a call
7//! whose input doesn't match what its view expects falls back to
8//! pretty-printed JSON: a tool that grows a field must not break a render.
9
10use 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
20/// How one tool call is set: what its summary line says, and what its fold
21/// holds. A call whose subject the summary already states in full has no body,
22/// and is set flat with no fold to open.
23pub struct Setting {
24    pub gist: Option<String>,
25    /// Where the gist points, when the call's subject is a URL.
26    pub href: Option<String>,
27    /// Qualifiers: options that change what the call does, which the subject
28    /// its body shows doesn't say.
29    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
78/// How a call is set, falling back to JSON for a tool with no view of its own
79/// and for one whose input doesn't match the shape its view expects.
80pub 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
109/// A one-line summary for a tool with no view of its own, drawn from whichever
110/// field carries the subject of the call. A call that describes itself is taken
111/// at its word: the description says what the call is *for*, which reads better
112/// folded than the command or prompt it stands in front of, and the body shows
113/// that anyway.
114fn 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
133/// A body that is written to be read: a prompt, a plan, a message, a report.
134/// Markdown is what these are composed in, so markdown is how they are set.
135fn 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
155/// A read has no body: the file it names and the lines it took are the whole
156/// call, and both fit on the summary line. The contents show up in the result.
157fn 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
164/// Which lines of a file a read asked for, or `None` when it asked for all
165/// of them, or when the numbers it asked with describe no span at all (a limit
166/// of zero takes nothing, and a limit that runs past the end of the counting
167/// names no last line).
168fn 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
202/// A checklist, labelled by whichever item is being worked: that is what the
203/// list was written to say.
204fn 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    // A create names its task; an update names only the id it changes.
272    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                                            // An option can carry a mockup of
326                                            // what choosing it would produce,
327                                            // which is what the reader actually
328                                            // compared, so it is shown as the
329                                            // laid-out thing it is.
330                                            @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
344/// A plan is a markdown document, so it is set as one. The prompts it asks to
345/// be pre-approved sit under it, since they are what the plan will run rather
346/// than part of what it says.
347fn 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
386/// The `name` a workflow script declares in its `meta` block, which says what
387/// the script is where a path or a name field doesn't.
388fn 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
396/// A message to another agent. The harness records the recipient and the body
397/// twice under different names; one of each is the whole message.
398fn 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
444/// How a result is set, which takes the call it answers: the wire format says
445/// only that some text came back, and what that text *is* (a file, a search, a
446/// terminal's output) is a fact about the tool that produced it.
447pub 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        // Output written to be read as prose, so it is set as prose.
468        Some("Agent" | "ExitPlanMode" | "Skill" | "WebFetch") => prose(scribe, text),
469        _ => plain(scribe, text),
470    }
471}
472
473/// What a tool answers when it has nothing to say beyond having done the thing:
474/// a prefix, and the suffix the sentence ends on where the subject sits in the
475/// middle of it. The parenthetical a file write appends is stripped first, so
476/// it needn't appear here.
477///
478/// The match is on the sentence rather than on the tool, because these results
479/// are not uniformly empty: an edit that also warns the file changed on disk
480/// says something a reader needs, and must not be swallowed with the rest.
481const 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    // A background agent answers with the id and output file the model needs to
494    // reach it again, and says so in as many words: none of it is addressed to
495    // a reader, and the call above it already shows what the agent was sent.
496    ("Agent", "Async agent launched successfully.", ""),
497    ("EnterPlanMode", "Entered plan mode.", ""),
498];
499
500/// The note the harness appends to a file write, addressed to the model rather
501/// than to anyone reading the folio.
502const FILE_STATE_NOTE: &str = "(file state is current in your context — no need to Read it back)";
503
504/// True when a result says only that the call above it was carried out. The
505/// call already shows the file it wrote or the task it changed, so the folio
506/// drops these rather than answering every edit with a line saying it worked.
507pub 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
519/// The two ways the harness introduces what a reader chose, and the sentence it
520/// may close with. Both are addressed to the model rather than to a reader.
521const 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
528/// Where an answered option's own preview is echoed back after its label.
529const SELECTED_PREVIEW: &str = "\" selected preview:";
530
531/// How the harness introduces text the reader typed instead of choosing one of
532/// the options. What follows is the answer; this is the frame around it.
533const TYPED_OPENING: &str = "(no option selected) notes:";
534
535/// One settled question: what was asked, what came back, and whether the reader
536/// typed it rather than taking one of the options offered.
537struct Answer<'a> {
538    question: &'a str,
539    chosen: &'a str,
540    typed: bool,
541}
542
543/// What was chosen, against what was asked. The harness answers as one long
544/// sentence naming each question back before its answer, so this recovers the
545/// pairs; the answer is what a reader wants, and the sentence buries it.
546///
547/// The pairs are found by anchoring on the `"=` between a question and its
548/// answer and then walking back from the *next* anchor to the `, "` that opens
549/// the following question. Splitting forward on the separators instead breaks
550/// on real transcripts: a question quotes code containing quotes, a free-text
551/// answer runs to several clauses, and an option that carried a preview has it
552/// echoed inline, all of which put the delimiters inside the values.
553fn 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    // Past the quote that opens the first question.
569    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        // An answer is quoted where an option was chosen, and bare where the
574        // reader typed something instead.
575        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        // The preview is already shown against the option it belongs to, in the
587        // call above, so only the label it followed is kept.
588        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
604/// What a reader chose. A result that answers nothing (the harness reports a
605/// question that timed out this way) has no pairs to find, and stands as the
606/// note it is.
607fn 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                    // A typed answer is marked as one: it took none of the
617                    // options above it, which is itself part of what was said.
618                    p .tool__chosen data-typed[answer.typed] { (answer.chosen) }
619                }
620            }
621        }
622    }
623}
624
625/// A failure is a diagnostic, so it is shown as it came, less the tag the
626/// harness wraps a tool's own error in.
627fn 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    // A failing command's diagnostics are where colour carries the most: it is
634    // what the tool used to mark the failure in the first place.
635    terminal(text)
636}
637
638/// Output with no shape of its own: pretty-printed where it is JSON (several
639/// tools answer with a JSON object on one line), and otherwise as the terminal
640/// wrote it, colour and all.
641fn 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
651/// The eight colours a terminal names, in the order ANSI numbers them. The
652/// stylesheet grinds each into one of the folio's own pigments, so a build log
653/// reads in the same palette as everything around it (see the classes-not-colors
654/// invariant in CLAUDE.md).
655const 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/// A colour a terminal asked for. The sixteen it *names* are the folio's to
671/// grind, so they resolve to a class and the stylesheet picks the pigment; a
672/// colour given outright as channels is the tool's own choice and no palette
673/// token can stand for it, so it is carried as the value it is.
674#[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/// How a terminal was writing when it wrote a run of its output. Tool results
697/// carry the escape codes verbatim, and a test run or a build log says what
698/// passed and what failed in green and red: dropping that leaves a reader with
699/// an undifferentiated wall, and showing it raw leaves them with the escapes.
700#[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    /// The classes this ink is drawn with, or `None` when it names nothing the
712    /// stylesheet owns.
713    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    /// The colours this ink states outright, which no class can stand for.
735    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    /// Applies one `ESC[...m` sequence's parameters.
747    fn apply(&mut self, params: &str) {
748        // An omitted parameter means zero, which is the reset.
749        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
780/// The colour an extended sequence asks for, taken from the parameters after
781/// the `38` or `48` that introduced it: `5;n` picks one of the 256, and
782/// `2;r;g;b` gives the channels outright.
783fn 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
794/// One of the 256 colours by index: the sixteen the terminal names, then a
795/// 6x6x6 cube, then a 24-step grey ramp. Only the first sixteen are the
796/// folio's to reinterpret; the rest are exact points the tool chose.
797fn 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            // The cube's six steps are not evenly spaced: the first is black,
804            // and the rest run from 95 up in steps of 40.
805            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
815/// A terminal's output with its colour kept: each run of text in the ink that
816/// was in force when it was written.
817fn 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
833/// Splits output into runs of text, each with the ink it was written in. Every
834/// escape sequence is consumed: the colour ones change the ink, and the rest
835/// (cursor moves, erases) are the terminal being driven rather than anything to
836/// show, so they leave nothing behind.
837fn 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            // A lone escape drives nothing, so it simply goes.
853            None => rest = &from_escape[1..],
854        }
855    }
856    if !rest.is_empty() {
857        runs.push((ink, rest));
858    }
859    runs
860}
861
862/// The `ESC[<params><final>` sequence at the start of `text`, as its
863/// parameters, its final byte, and what follows it.
864fn control_sequence(text: &str) -> Option<(&str, char, &str)> {
865    let params = text.strip_prefix("\u{1b}[")?;
866    // Parameter and intermediate bytes run up to the final byte, which is the
867    // first in the range @ to ~ and is what says which sequence this is.
868    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
875/// A file's contents, set the way the file itself would be. The harness numbers
876/// the lines it returns; those numbers are the harness talking, not the file,
877/// so they come off before the file is highlighted by its extension.
878fn 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
883/// A numbered listing with its numbering taken off, or the text unchanged when
884/// it isn't one: a file whose own lines start with numbers must not lose them.
885fn 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
890/// One listing line without its `<spaces><number><tab>` prefix. A blank line
891/// carries no number and stands for itself.
892fn 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
900/// A web search answers with a line of JSON links between two of prose. The
901/// links are the result, so they are set as links.
902fn 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
929/// A background task answers with its status in pseudo-XML tags and its work in
930/// an `output` tag: the status is a handful of facts and the output is prose.
931fn 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
949/// The `<tag>value</tag>` pairs in a harness answer, in the order they appear.
950fn 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
968/// What a result says, however the harness wrote it down: a result that came
969/// back as text blocks says the same thing a plain-text one does, so it reads
970/// the same way, both when it is set and when it is weighed as an
971/// acknowledgement. Blocks that aren't all text are content in their own right,
972/// and come back as themselves.
973pub 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
980/// What a result made only of text blocks says, or `None` when it carries
981/// anything else (an image, a reference) that is content in its own right.
982fn 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
994/// A result that came back as blocks rather than text. A tool search answers
995/// with references to the tools it found, which are names; everything else is
996/// content in its own right and renders as the block it is.
997fn 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
1015/// The language token for a path, taken from its extension. syntect resolves it
1016/// by extension or name, so the bare extension is enough; an empty token (no
1017/// extension) highlights as plain text.
1018fn lang_for_path(path: &str) -> &str {
1019    Path::new(path)
1020        .extension()
1021        .and_then(|extension| extension.to_str())
1022        .unwrap_or("")
1023}
1024
1025/// An `Edit`'s before/after rendered as a diff body: the old lines removed, the
1026/// new lines added. The `diff` lexer colours each line by its leading marker,
1027/// reusing the inserted/deleted scope palette the stylesheet already defines.
1028fn 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
1047/// The leading `# ` heading of a markdown document, which titles it.
1048fn heading(document: &str) -> Option<&str> {
1049    document
1050        .lines()
1051        .find_map(|line| line.strip_prefix("# "))
1052        .map(str::trim)
1053}
1054
1055/// A timeout in the units a reader thinks in, from the milliseconds the harness
1056/// records.
1057fn 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        // Numbers off the wire, so a limit of zero and one that runs past the
1081        // end of the counting are both shapes a transcript can carry. Neither
1082        // names lines, and neither is worth failing a render over.
1083        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        // Nothing here is a listing prefix, so the text stands as it is rather
1098        // than losing a column of real content.
1099        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        // The edit worked, but the file moved underneath it: that warning is
1152        // the only place a reader learns of it.
1153        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        // The same sentence from another tool is not an acknowledgement of a
1159        // call this one never made.
1160        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        // Erase-line and cursor-home say nothing about the text around them,
1186        // and neither does a lone escape.
1187        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        // A palette token can stand for these, so they resolve to a class and
1196        // carry no colour of their own.
1197        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        // The 6x6x6 cube, the grey ramp, and a 24-bit colour: none of these is
1207        // a colour the folio has a name for.
1208        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}