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    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
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.
135pub(crate) fn prose(scribe: &Scribe, source: &str) -> Markup {
136    html! { div .tool.tool--prose { (scribe.markdown(source)) } }
137}
138
139/// A body a program printed rather than composed: read as markdown, but keeping
140/// the line breaks it printed. See [`Scribe::markdown_printed`].
141pub(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
161/// A read has no body: the file it names and the lines it took are the whole
162/// call, and both fit on the summary line. The contents show up in the result.
163fn 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
170/// Which lines of a file a read asked for, or `None` when it asked for all
171/// of them, or when the numbers it asked with describe no span at all (a limit
172/// of zero takes nothing, and a limit that runs past the end of the counting
173/// names no last line).
174fn 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
208/// A checklist, labelled by whichever item is being worked: that is what the
209/// list was written to say.
210fn 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    // A create names its task; an update names only the id it changes.
278    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                                            // An option can carry a mockup of
332                                            // what choosing it would produce,
333                                            // which is what the reader actually
334                                            // compared, so it is shown as the
335                                            // laid-out thing it is.
336                                            @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
350/// A plan is a markdown document, so it is set as one. The prompts it asks to
351/// be pre-approved sit under it, since they are what the plan will run rather
352/// than part of what it says.
353fn 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
392/// The `name` a workflow script declares in its `meta` block, which says what
393/// the script is where a path or a name field doesn't.
394fn 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
402/// A message to another agent. The harness records the recipient and the body
403/// twice under different names; one of each is the whole message.
404fn 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
450/// The error text with the tag the harness wraps it in shed.
451fn 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
458/// The first thing a result has to say, for its summary line.
459///
460/// A result sits in the panel holding the call it answers, so naming that call
461/// again only repeats the line above it. What the line has no other way to show
462/// is what came *back*, so it previews that instead and the fold holds the rest.
463///
464/// **A hint must show what the fold shows.** The match below therefore shadows
465/// [`result`]'s, arm for arm: where a view sheds something on the way into the
466/// fold the hint sheds the same thing, and where a view recomposes the text the
467/// hint is drawn from what the fold leads with rather than from the markup it
468/// arrived in. Taking the raw line instead puts back onto the summary exactly
469/// what the view took out of the fold, which is how a read came to open on its
470/// own line number. Each arm also follows its view's *fallback*: a shape that
471/// doesn't parse is set as it came in both places. **Adding an arm to `result`
472/// means deciding its arm here.**
473///
474/// The escapes a terminal wrote are dropped: colour is a fact about the body,
475/// which sets it, and a hint carrying them would show their bytes as text. Blank
476/// lines are skipped, so a file that opens on one still hints at its first real
477/// line. A result that is an image or a reference has no line to take at all.
478///
479/// The hint is cut well past the column's width rather than at it: the gist
480/// ellipsises in the stylesheet, which is what decides where a reader sees it
481/// end, and this only keeps a single-line result of many kilobytes out of the
482/// markup.
483pub 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        // A tool search answers with references rather than with text, and the
493        // fold sets them as the list of names they are, so the hint names them
494        // too: without this the one line a reader sees of a search's answer was
495        // blank. Anything else that comes back as blocks is content in its own
496        // right (an image, a nested result) and has no line to preview.
497        Err(blocks) => Cow::Owned(
498            references(answering.map(|answered| answered.tool.as_str()), blocks)?.join(", "),
499        ),
500    };
501    let shown = if is_error {
502        // A failure's own first line is the most useful hint there is, and the
503        // tag around it is the harness's rather than the tool's.
504        Cow::Borrowed(shed_error_tag(text.as_ref()))
505    } else {
506        match answering.map(|answered| answered.tool.as_str()) {
507            // The numbering down a listing's edge is the harness's rather than
508            // the file's, and `source` takes it off on the way into the fold.
509            Some("Read") => Cow::Owned(unnumbered(text.as_ref())),
510            // The fold shows the answers parsed out of the sentence they arrive
511            // buried in, so the hint is the first thing chosen, which is what a
512            // reader opened the fold for. A sentence that doesn't parse is set
513            // as it came, in the fold and here alike.
514            Some("AskUserQuestion") => match answers(text.as_ref()) {
515                Some(answered) => Cow::Owned(answered.first()?.chosen.to_string()),
516                None => text,
517            },
518            // The fold leads with the tags as a list of facts, so the hint is
519            // the first of them rather than the markup they arrive in.
520            Some("TaskOutput") => match tags(text.as_ref()).first() {
521                Some((_, value)) => Cow::Owned((*value).to_owned()),
522                None => text,
523            },
524            // Everything else reaches the fold as it came, whether as prose, as
525            // a search's own opening line, or as the terminal wrote it.
526            _ => 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
539/// How a result is set, which takes the call it answers: the wire format says
540/// only that some text came back, and what that text *is* (a file, a search, a
541/// terminal's output) is a fact about the tool that produced it.
542pub 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        // Output written to be read as prose, so it is set as prose.
563        Some("Agent" | "ExitPlanMode" | "Skill" | "WebFetch") => prose(scribe, text),
564        _ => plain(scribe, text),
565    }
566}
567
568/// What a tool answers when it has nothing to say beyond having done the thing:
569/// a prefix, and the suffix the sentence ends on where the subject sits in the
570/// middle of it. The parenthetical a file write appends is stripped first, so
571/// it needn't appear here.
572///
573/// The match is on the sentence rather than on the tool, because these results
574/// are not uniformly empty: an edit that also warns the file changed on disk
575/// says something a reader needs, and must not be swallowed with the rest.
576const 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    // A background agent answers with the id and output file the model needs to
589    // reach it again, and says so in as many words: none of it is addressed to
590    // a reader, and the call above it already shows what the agent was sent.
591    ("Agent", "Async agent launched successfully.", ""),
592    ("EnterPlanMode", "Entered plan mode.", ""),
593];
594
595/// The note the harness appends to a file write, addressed to the model rather
596/// than to anyone reading the folio.
597const FILE_STATE_NOTE: &str = "(file state is current in your context — no need to Read it back)";
598
599/// True when a result says only that the call above it was carried out. The
600/// call already shows the file it wrote or the task it changed, so the folio
601/// drops these rather than answering every edit with a line saying it worked.
602pub 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
614/// The two ways the harness introduces what a reader chose, and the sentence it
615/// may close with. Both are addressed to the model rather than to a reader.
616const 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
623/// Where an answered option's own preview is echoed back after its label.
624const SELECTED_PREVIEW: &str = "\" selected preview:";
625
626/// How the harness introduces text the reader typed instead of choosing one of
627/// the options. What follows is the answer; this is the frame around it.
628const TYPED_OPENING: &str = "(no option selected) notes:";
629
630/// One settled question: what was asked, what came back, and whether the reader
631/// typed it rather than taking one of the options offered.
632struct Answer<'a> {
633    question: &'a str,
634    chosen: &'a str,
635    typed: bool,
636}
637
638/// What was chosen, against what was asked. The harness answers as one long
639/// sentence naming each question back before its answer, so this recovers the
640/// pairs; the answer is what a reader wants, and the sentence buries it.
641///
642/// The pairs are found by anchoring on the `"=` between a question and its
643/// answer and then walking back from the *next* anchor to the `, "` that opens
644/// the following question. Splitting forward on the separators instead breaks
645/// on real transcripts: a question quotes code containing quotes, a free-text
646/// answer runs to several clauses, and an option that carried a preview has it
647/// echoed inline, all of which put the delimiters inside the values.
648fn 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    // Past the quote that opens the first question.
664    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        // An answer is quoted where an option was chosen, and bare where the
669        // reader typed something instead.
670        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        // The preview is already shown against the option it belongs to, in the
682        // call above, so only the label it followed is kept.
683        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
699/// What a reader chose. A result that answers nothing (the harness reports a
700/// question that timed out this way) has no pairs to find, and stands as the
701/// note it is.
702fn 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                    // A typed answer is marked as one: it took none of the
712                    // options above it, which is itself part of what was said.
713                    p .tool__chosen data-typed[answer.typed] { (answer.chosen) }
714                }
715            }
716        }
717    }
718}
719
720/// A failure is a diagnostic, so it is shown as it came, less the tag the
721/// harness wraps a tool's own error in.
722fn failure(text: &str) -> Markup {
723    // A failing command's diagnostics are where colour carries the most: it is
724    // what the tool used to mark the failure in the first place.
725    terminal(shed_error_tag(text))
726}
727
728/// Output with no shape of its own: pretty-printed where it is JSON (several
729/// tools answer with a JSON object on one line), and otherwise as the terminal
730/// wrote it, colour and all.
731pub(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
741/// The eight colours a terminal names, in the order ANSI numbers them. The
742/// stylesheet grinds each into one of the folio's own pigments, so a build log
743/// reads in the same palette as everything around it (see the classes-not-colors
744/// invariant in CLAUDE.md).
745const 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/// A colour a terminal asked for. The sixteen it *names* are the folio's to
761/// grind, so they resolve to a class and the stylesheet picks the pigment; a
762/// colour given outright as channels is the tool's own choice and no palette
763/// token can stand for it, so it is carried as the value it is.
764#[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/// How a terminal was writing when it wrote a run of its output. Tool results
787/// carry the escape codes verbatim, and a test run or a build log says what
788/// passed and what failed in green and red: dropping that leaves a reader with
789/// an undifferentiated wall, and showing it raw leaves them with the escapes.
790#[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    /// The classes this ink is drawn with, or `None` when it names nothing the
802    /// stylesheet owns.
803    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    /// The colours this ink states outright, which no class can stand for.
825    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    /// Applies one `ESC[...m` sequence's parameters.
837    fn apply(&mut self, params: &str) {
838        // An omitted parameter means zero, which is the reset.
839        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
870/// The colour an extended sequence asks for, taken from the parameters after
871/// the `38` or `48` that introduced it: `5;n` picks one of the 256, and
872/// `2;r;g;b` gives the channels outright.
873fn 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
884/// One of the 256 colours by index: the sixteen the terminal names, then a
885/// 6x6x6 cube, then a 24-step grey ramp. Only the first sixteen are the
886/// folio's to reinterpret; the rest are exact points the tool chose.
887fn 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            // The cube's six steps are not evenly spaced: the first is black,
894            // and the rest run from 95 up in steps of 40.
895            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
905/// A terminal's output with its colour kept: each run of text in the ink that
906/// was in force when it was written.
907/// What a terminal was left showing, for output that redrew itself.
908///
909/// A carriage return sends the cursor back to the start of the line, so what
910/// follows it is written *over* what came before: a spinner emits one frame per
911/// return and a progress bar one bar, and a reader watching saw only the last of
912/// them. Set as they came, those frames have no newlines between them and run
913/// together into one unreadable line dozens of frames long, which is the shape a
914/// build log most often takes.
915///
916/// Each line therefore keeps only what follows its final return. This is the
917/// coarse reading rather than a faithful one, since a return that rewrites part
918/// of a line leaves the rest standing, but nothing in the corpus does that: the
919/// returns there are all whole-line redraws. `\r\n` is a line ending rather than
920/// a redraw and is normalised first, or every line of a file written on Windows
921/// would be cut to nothing.
922fn 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
951/// Splits output into runs of text, each with the ink it was written in. Every
952/// escape sequence is consumed: the colour ones change the ink, and the rest
953/// (cursor moves, erases) are the terminal being driven rather than anything to
954/// show, so they leave nothing behind.
955fn 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            // A lone escape drives nothing, so it simply goes.
971            None => rest = &from_escape[1..],
972        }
973    }
974    if !rest.is_empty() {
975        runs.push((ink, rest));
976    }
977    runs
978}
979
980/// The `ESC[<params><final>` sequence at the start of `text`, as its
981/// parameters, its final byte, and what follows it.
982fn control_sequence(text: &str) -> Option<(&str, char, &str)> {
983    let params = text.strip_prefix("\u{1b}[")?;
984    // Parameter and intermediate bytes run up to the final byte, which is the
985    // first in the range @ to ~ and is what says which sequence this is.
986    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
993/// A file's contents, set the way the file itself would be. The harness numbers
994/// the lines it returns; those numbers are the harness talking, not the file,
995/// so they come off before the file is highlighted by its extension.
996fn 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
1001/// A numbered listing with its numbering taken off, or the text unchanged when
1002/// it isn't one: a file whose own lines start with numbers must not lose them.
1003fn 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
1008/// One listing line without its `<spaces><number><tab>` prefix. A blank line
1009/// carries no number and stands for itself.
1010fn 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
1018/// A web search answers with a line of JSON links between two of prose. The
1019/// links are the result, so they are set as links.
1020fn 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
1047/// A background task answers with its status in pseudo-XML tags and its work in
1048/// an `output` tag: the status is a handful of facts and the output is prose.
1049fn 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
1067/// The `<tag>value</tag>` pairs in a harness answer, in the order they appear.
1068fn 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
1086/// What a result says, however the harness wrote it down: a result that came
1087/// back as text blocks says the same thing a plain-text one does, so it reads
1088/// the same way, both when it is set and when it is weighed as an
1089/// acknowledgement. Blocks that aren't all text are content in their own right,
1090/// and come back as themselves.
1091pub 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
1098/// What a result made only of text blocks says, or `None` when it carries
1099/// anything else (an image, a reference) that is content in its own right.
1100fn 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
1112/// The tools a search found, when that is the whole of what came back. Every
1113/// block must be a reference: a result carrying anything else is content in its
1114/// own right and is set as the blocks it is.
1115fn 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
1129/// A result that came back as blocks rather than text. A tool search answers
1130/// with references to the tools it found, which are names; everything else is
1131/// content in its own right and renders as the block it is.
1132fn 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
1143/// The language token for a path, taken from its extension. syntect resolves it
1144/// by extension or name, so the bare extension is enough; an empty token (no
1145/// extension) highlights as plain text.
1146fn lang_for_path(path: &str) -> &str {
1147    Path::new(path)
1148        .extension()
1149        .and_then(|extension| extension.to_str())
1150        .unwrap_or("")
1151}
1152
1153/// An `Edit`'s before/after rendered as a diff body: the old lines removed, the
1154/// new lines added. The `diff` lexer colours each line by its leading marker,
1155/// reusing the inserted/deleted scope palette the stylesheet already defines.
1156fn 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
1175/// The leading `# ` heading of a markdown document, which titles it.
1176fn heading(document: &str) -> Option<&str> {
1177    document
1178        .lines()
1179        .find_map(|line| line.strip_prefix("# "))
1180        .map(str::trim)
1181}
1182
1183/// A timeout in the units a reader thinks in, from the milliseconds the harness
1184/// records.
1185fn 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        // Numbers off the wire, so a limit of zero and one that runs past the
1209        // end of the counting are both shapes a transcript can carry. Neither
1210        // names lines, and neither is worth failing a render over.
1211        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        // Nothing here is a listing prefix, so the text stands as it is rather
1226        // than losing a column of real content.
1227        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        // The edit worked, but the file moved underneath it: that warning is
1280        // the only place a reader learns of it.
1281        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        // The same sentence from another tool is not an acknowledgement of a
1287        // call this one never made.
1288        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        // Erase-line and cursor-home say nothing about the text around them,
1314        // and neither does a lone escape.
1315        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        // A palette token can stand for these, so they resolve to a class and
1324        // carry no colour of their own.
1325        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        // The 6x6x6 cube, the grey ramp, and a 24-bit colour: none of these is
1335        // a colour the folio has a name for.
1336        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}