Files
multica/packages/views/common/task-transcript/diff-highlight.ts
Larry Lai da7451843b MUL-5494: feat(transcript): render tool events as diffs, content and terminal output (#6134)
* feat(transcript): make tool events readable — diffs, content, terminal output

The expanded transcript row printed a tool call's input as raw JSON, so an edit
showed `old_string`/`new_string` as escaped one-line literals — the one event
type where seeing *what changed* matters most. Tool results kept their JSON
string encoding, so every shell result read as a quoted blob with literal `\n`,
in the collapsed summary and in Copy all as well as in the body.

What each kind of event now renders as:

- A replacement reads as a diff. Unchanged runs fold to `⋯` with three lines of
  context either side, so a one-line change inside a large `old_string` is not
  buried in text that never moved.
- A whole-file write reads as plain content with a line count. There is no
  before side to compare against, so a `+` on all of it carries no information.
- A result is unwrapped once, everywhere it appears.

File mutations are identified by the *shape* of the input (`file_path` plus
`old_string`/`new_string`, or `content`), never by tool name: the presenter's
contract is to keep provider-native names verbatim, and those differ per
provider. The write mode keys on `content` rather than on "the before side is
empty", because an edit with an empty old_string is an insertion into a file
that already exists and still reads as a diff.

Highlighting reuses the rich-content engine (`lowlight` and the `.hljs-*` class
contract), so a file looks the same in a transcript as it does in a comment,
with no new dependency. Each side is highlighted as ONE block and then split at
newlines, re-opening the enclosing spans per line — highlighting line by line
would break every multi-line string, comment and template literal. Grammar
comes from the file extension; an unknown extension stays plain rather than
guessing. The hljs palette was scoped to `.rich-text-editor`; it now also
covers `.transcript-code`, with no colour definition duplicated.

Diffing is a small LCS over lines, degrading to a plain replacement block past
250k cells. Line numbers are deliberately absent: the transcript stores only
the tool input, so a snippet's position inside its file is not knowable here,
and relative numbers would read as file lines and mislead.

* fix(transcript): keep the show-all label off the line it covers

The fade overlay does not fully clear the clipped line, so the transparent
"Show all" label rendered on top of whatever text sat behind it — the two
interleaved character by character and neither was readable. Giving the button
an opaque surface separates them.

Pre-existing: any tool output long enough to clip hit it. It became routine
once whole-file writes started rendering their content.
2026-07-30 10:22:14 +08:00

135 lines
4.1 KiB
TypeScript

// Syntax highlighting for transcript diffs. Highlighting runs over a whole
// side at once — never line by line — so a multi-line string, comment or
// template literal is coloured as the one token it is. The highlighted tree is
// then split at newlines, re-opening the enclosing spans on each line, which is
// what lets a per-line diff gutter coexist with block-accurate grammar.
//
// Same engine (`lowlight`) and same `.hljs-*` class contract as the rich
// content code block, so a Rust file looks the same in a transcript as it does
// in a comment.
import { toHtml } from "hast-util-to-html";
import type { Element, ElementContent, Properties, Root, RootContent } from "hast";
import { highlightCode } from "../../editor/syntax-highlight";
/**
* File extension to a lowlight grammar name. Unlisted extensions resolve to
* plaintext inside `highlightCode`, so this map only needs the languages worth
* naming — a miss degrades to unhighlighted text, never to an error.
*/
const LANGUAGE_BY_EXTENSION: Record<string, string> = {
bash: "bash",
c: "c",
cc: "cpp",
cjs: "javascript",
cpp: "cpp",
cs: "csharp",
css: "css",
go: "go",
h: "c",
hpp: "cpp",
htm: "xml",
html: "xml",
java: "java",
js: "javascript",
json: "json",
jsx: "javascript",
kt: "kotlin",
lua: "lua",
md: "markdown",
mjs: "javascript",
php: "php",
pl: "perl",
py: "python",
r: "r",
rb: "ruby",
rs: "rust",
scala: "scala",
scss: "scss",
sh: "bash",
sql: "sql",
swift: "swift",
toml: "ini",
ts: "typescript",
tsx: "typescript",
xml: "xml",
yaml: "yaml",
yml: "yaml",
zsh: "bash",
};
/** Grammar for a path, by extension. Undefined means "highlight as plaintext". */
export function languageForPath(path: string): string | undefined {
const base = path.split("/").pop() ?? path;
const dot = base.lastIndexOf(".");
if (dot <= 0) return undefined;
return LANGUAGE_BY_EXTENSION[base.slice(dot + 1).toLowerCase()];
}
/** The open-element chain a piece of text sits inside, innermost last. */
type Ancestors = ReadonlyArray<Pick<Element, "tagName" | "properties">>;
function wrap(text: string, ancestors: Ancestors): ElementContent {
let node: ElementContent = { type: "text", value: text };
for (let i = ancestors.length - 1; i >= 0; i--) {
const ancestor = ancestors[i];
if (!ancestor) continue;
node = {
type: "element",
tagName: ancestor.tagName,
properties: (ancestor.properties ?? {}) as Properties,
children: [node],
};
}
return node;
}
function collect(
nodes: ReadonlyArray<RootContent>,
ancestors: Ancestors,
lines: ElementContent[][],
): void {
for (const node of nodes) {
if (node.type === "text") {
const parts = node.value.split("\n");
for (let i = 0; i < parts.length; i++) {
if (i > 0) lines.push([]);
const part = parts[i];
if (part === undefined || part.length === 0) continue;
lines[lines.length - 1]?.push(wrap(part, ancestors));
}
continue;
}
if (node.type === "element") {
collect(node.children, [...ancestors, { tagName: node.tagName, properties: node.properties }], lines);
}
// Comments/doctypes cannot appear in a lowlight result; ignoring them keeps
// the walker total without inventing output for impossible input.
}
}
/**
* Highlight `text` as one block and return one HTML string per line. Falls back
* to `null` so callers can render the original text unhighlighted rather than
* showing nothing when a grammar throws.
*/
/** Highlight `text` as one block. `null` when the grammar throws. */
export function highlightBlock(text: string, language: string | undefined): string | null {
try {
return toHtml(highlightCode(text, language) as Root);
} catch {
return null;
}
}
export function highlightToLines(text: string, language: string | undefined): string[] | null {
try {
const tree = highlightCode(text, language) as Root;
const lines: ElementContent[][] = [[]];
collect(tree.children, [], lines);
return lines.map((children) => toHtml({ type: "root", children }));
} catch {
return null;
}
}