Claude Code in the terminal with enhanced visuals, syntax highlighting, tool use, attached images, and more.

Veneer is a Claude Code mod that enhances the overall look and feel of the terminal. It uses the built-in Claude Code theming system for nearly everything. It adds customizable syntax highlighting for over 100 languages, using any theme on Open VSX or any theme in VS Code theme format. Furthermore, it can display attached and read images in the chat.
Use the following slash commands inside Claude Code (requires Claude Code>=2.1.287):
/plugin marketplace add mitchmedeiros/veneer
/plugin install veneer@veneer
Or do both in one command from your shell (requires Claude Code>=2.1.292):
claude plugin install veneer --marketplace mitchmedeiros/veneer
Veneer works in any terminal. The sections below cover optional setup for specific terminals and features.
To see diagrams and tables as images, and image previews in messages, you need:
This is a limitation of how Claude Code displays images.
If you want Nerd Font icons rather than Unicode, either use a terminal that ships with them built in or install them. If you still see Unicode symbols in Claude Code, enable the icons with /veneer icons nerd.
Launch Claude Code with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 to display images in Rio. In my testing, pictures look small with the default cellRatio, and a value of 1.9 fits better. Run /veneer cell-proportion 1.9 or change it in the settings pane.
For images in tmux, run tmux set -g allow-passthrough on (tmux>=3.3a). Then start Claude Code with CLAUDE_CODE_FORCE_TERMINAL_IMAGES=1 and run /veneer text-based graphics off.
Run /syntax to pick a theme. It lists every theme file in ~/.claude/veneer/themes/.
To add a new theme, either choose Browse popular themes to download one of the 50 most popular, or choose New custom theme and provide:
publisher.name for a theme on Open VSXIf a theme on Open VSX has multiple variants (e.g. light, dark), you will be asked to choose one.
Run /veneer to change every option below in one pane. It has a General tab and a Syntax tab.
To change a setting without opening the pane, run /veneer <setting> <value>, such as /veneer icons nerd. The setting can be either its display name or its option name, in any case: /veneer cell-proportion 1.9 and /veneer cellRatio 1.9 do the same thing. Switches take on or off.
| Display Name | Setting | Default | Use |
|---|---|---|---|
| Message alignment | messageAlign | left | left or right |
| Message styling | styleUserMessage | true | Draw your messages as a bubble. Off leaves it to a mod. |
| Icons | icons | auto | auto, nerd or plain. Nerd Font or Unicode icons. |
| File links | links | auto | auto, link or text. How file paths are drawn. |
| Text-based graphics | nativeGraphics | false | Draw tables, headings and Mermaid diagrams as text. |
| Diagram hint | diagramHint | true | Off stops Claude from responding with diagrams. |
| Spinner styling | styleSpinner | true | Turn off to leave spinner styling to another mod. |
| Table font | fontFamily | blank | Font for tables and headings. Blank uses defaults. |
| Cell proportion | cellRatio | 2.9 | Terminal cell height over width. Fixes letterboxing. |
| Code blocks | fenceBackground | true | Fill code blocks with the syntax theme's background. |
| File reads | readBackground | true | The same for the card of a file read. |
| Command and search output | outputBackground | true | The same for highlighted Bash and Grep output. |
| Diffs | diffBackground | true | The same for diffs. Added and removed lines keep theirs. |
Settings are stored under pluginConfigs in the global ~/.claude/settings.json.
Veneer instructs Claude to draw mermaid fences when they add value. Veneer then parses these and displays them as images or text blocks. The supported types are:
| Kind | Opens with |
|---|---|
| Flowchart | graph or flowchart, without subgraphs |
| State | stateDiagram or stateDiagram-v2 |
| Sequence | sequenceDiagram |
| Class | classDiagram |
| ER | erDiagram |
| Pie | pie |
| XY chart | xychart or xychart-beta, bars and lines, vertical or horizontal |
| Radar | radar-beta |
| Sankey | sankey or sankey-beta |
| Treemap | treemap-beta |
| Quadrant chart | quadrantChart |
| Gantt | gantt, with dependencies, milestones, dateFormat and axisFormat |
| Timeline | timeline |
| User journey | journey |
| Kanban | kanban, with ticket, assignee and priority metadata |
| Mind map | mindmap |
| Git graph | gitGraph, with branches, merges, tags and cherry-picks |
| Packet | packet or packet-beta |
| Block | block-beta, with spans, spaces, nested blocks and arrows |
| Architecture | architecture-beta, with groups, services, junctions and ports |
| C4 | C4Context, C4Container, C4Component, C4Dynamic and C4Deployment |
Syntax highlighting is supported for 106 languages and file types:
Requires Node 24 or later and the Claude Code CLI. Install the tooling once with npm install, which also sets up the pre-commit hook.
To test local changes, start Claude Code with claude --plugin-dir /path/to/veneer. For that session it loads your checkout instead of an installed copy of Veneer.
Other useful commands:
| Command | Does |
|---|---|
npm run typecheck | Type-check with tsc |
npm run validate | Run claude plugin validate on the repo |
npm test | Run claude plugin test on the repo |
npm run build:grammars | Rebuild grammars/ from tools/languages.json |
npm run conformance | Compare the tokenizer with Shiki on tools/samples/ |
npm run check:themes | Fetch the popular themes from Open VSX (network, not in CI; -- --all for every theme) |
hooks/register.tsx 1114 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, PluginOptions, Register, RenderElement } from "claude-code";
3
4import type { Off, Palette, SettingsPane, SyntaxPane, Tab } from "../types";
5import { configOf } from "./config";
6import type { Backdrop, Config } from "./config";
7import { iconsArePlain, linksArePlain, OFF_TEXT, terminalOff } from "./gate";
8import { changeCounts, withBadges } from "./badge";
9import { diffCard, parseDiff } from "./diff";
10import { MAX_LINES } from "./fold";
11import { toolGroup } from "./group";
12import { headingSketch } from "./heading";
13import { altOf, imageSketch, imagesOf, pastedImagesOf, PASTED_MARKER, sizeOfFound } from "./image";
14import type { Found } from "./image";
15import { imageColumn } from "./image-card";
16import type { Shown } from "./image-card";
17import { hasDrawn, splitReply } from "./markdown";
18import type { Segment } from "./markdown";
19import { outputCard, parseOutput } from "./output";
20import { baseOf } from "./palette";
21import { cellHeightOf, hashOf, MAX_COLUMNS, pictureFor } from "./picture";
22import type { Font, Picture, PictureIo, Sketch } from "./picture";
23import { CARD_COLUMNS } from "./mermaid-card";
24import { diagramSketch, diagramText } from "./mermaid-diagram";
25import { MERMAID_HINT } from "./mermaid-hint";
26import { BULLET, GUTTER, MARGIN, replyTree } from "./reply";
27import { commandOfCall, pathOfCall, rememberInput } from "./calls";
28import { filePathOf, outputFill, paintCommand, paintDiff, paintFences, paintLines, paintOutput } from "./paint";
29import type { Painter } from "./paint";
30import { highlight, themeBackground, themeSwatch } from "./syntax/highlight";
31import type { SyntaxIo } from "./syntax/highlight";
32import {
33 DEFAULT_THEME,
34 NEW_THEME,
35 paneKeys,
36 PATH_KEY,
37 POPULAR_THEME,
38 SAMPLES,
39 syntaxPane,
40 THEME_KEY,
41} from "./syntax-pane";
42import { POPULAR_THEMES } from "./syntax-popular";
43import { fileUrl, flattenTheme, needsStrings, OPEN_VSX_ORIGIN, parseExtensionId, themesOf } from "./syntax-remote";
44import type { ExtensionId, ThemeChoice } from "./syntax-remote";
45import {
46 baseNameOf,
47 configDirOf,
48 expandHome,
49 importName,
50 themeNames,
51 themeProblem,
52 themesDirOf,
53 THEME_EXTENSION,
54 uniqueName,
55} from "./syntax-themes";
56import {
57 fieldOf,
58 groupSettings,
59 isTyped,
60 nextValue,
61 parseSettingChange,
62 SETTING_KEY,
63 SETTINGS_PREFIX,
64 settingsKeys,
65 settingsPane,
66 valueText,
67} from "./settings-pane";
68import type { Setting } from "./settings-pane";
69import { PANE_ID, tabbed, tabKeys } from "./pane-tabs";
70import { tableSketch } from "./table";
71import { toolRow } from "./tool-row";
72import type { Plain } from "./tool-row";
73import { loadPalette } from "./theme";
74import { userBubble } from "./user-message";
75
76const NOTICE_MS = 6000;
77
78// Why Veneer is off this session; null when it draws.
79const offAtom = atom({ plugin: "veneer", key: "off" } as const, null as Off | null);
80// Whether the icons are Unicode symbols, because the terminal has no Nerd Font to draw them or the setting says so.
81const plainIconsAtom = atom({ plugin: "veneer", key: "plainIcons" } as const, false);
82// Whether a file path is plain text, because Claude Code would print its address beside a link in this terminal.
83const plainLinksAtom = atom({ plugin: "veneer", key: "plainLinks" } as const, false);
84// The tool calls whose diff card shows every line.
85const expandedAtom = atom({ plugin: "veneer", key: "expanded" } as const, [] as string[]);
86const paletteAtom = atom({ plugin: "veneer", key: "palette" } as const, baseOf("dark") as Palette);
87// The syntax theme chosen with /syntax, by file name without `.json`; empty is the shipped default. Kept in the store.
88const syntaxAtom = atom({ plugin: "veneer", key: "syntax" } as const, "");
89const LIST_PANE: SyntaxPane = {
90 step: "list",
91 path: "",
92 content: "",
93 message: undefined,
94 page: 0,
95 extension: "",
96 variants: [],
97};
98const syntaxPaneAtom = atom({ plugin: "veneer", key: "syntaxPane" } as const, LIST_PANE);
99// The theme the picker's focus is on. It shows in place of the chosen one until the pane closes, as Claude's own theme
100// picker does; null when nothing is previewed.
101const syntaxPreviewAtom = atom({ plugin: "veneer", key: "syntaxPreview" } as const, null as string | null);
102// The tab of the pane that is open.
103const tabAtom = atom({ plugin: "veneer", key: "tab" } as const, "settings" as Tab);
104// The element the pane's focus ring is on, which the arrow keys move from when the pane would scroll.
105const focusAtom = atom({ plugin: "veneer", key: "focus" } as const, null as string | null);
106
107// The plugin's settings as they stand, by field name. Hooks read them from here so a change made in the `/veneer` pane or
108// in `/config` shows at once.
109const optionsAtom = atom({ plugin: "veneer", key: "options" } as const, {} as PluginOptions);
110const CLOSED_SETTINGS: SettingsPane = { editing: null, message: undefined };
111const settingsPaneAtom = atom({ plugin: "veneer", key: "settingsPane" } as const, CLOSED_SETTINGS);
112
113// The slash commands: each opens the pane, on the tab it names.
114const SYNTAX_COMMAND = "syntax";
115const SETTINGS_COMMAND = "veneer-settings";
116// What the pane asks for, as a request: rows when seated above the prompt, columns when docked beside the transcript.
117const PANE_ROWS = 24;
118const PANE_COLUMNS = 72;
119const SYNTAX_KEY = "syntaxTheme";
120const MAX_REDIRECTS = 3;
121
122// Opens a folded entry, or folds an open one again.
123function flip($: EngineInterface, id: string): void {
124 void update($, expandedAtom, (ids) => (ids.includes(id) ? ids.filter((other) => other !== id) : [...ids, id]));
125}
126
127async function themeFile($: EngineInterface, path: string): Promise<string | undefined> {
128 return $.fs.read(path).catch(() => undefined);
129}
130
131async function refreshPalette($: EngineInterface, theme?: unknown): Promise<void> {
132 const setting = theme ?? (await $.config.list()).find((row) => row.key === "theme")?.value;
133 const palette = await loadPalette(setting, await $.env.get("HOME"), await $.env.get("COLORFGBG"), (path) =>
134 themeFile($, path),
135 );
136
137 await update($, paletteAtom, () => palette);
138}
139
140const pictureIo = ($: EngineInterface): PictureIo => ({
141 run: async (argv, stdin) => (await $.process.run(argv, { stdin, timeoutMs: 5000 }).catch(() => null))?.exitCode === 0,
142 readBase64: (path) =>
143 $.fs.read(path, { as: "bytes" }).then(
144 (file) => file.base64,
145 () => undefined,
146 ),
147});
148
149// The folder the person's syntax themes are kept in, or undefined when no Claude folder can be found.
150async function themesFolder($: EngineInterface): Promise<string | undefined> {
151 const [configDir, home] = await Promise.all([$.env.get("CLAUDE_CONFIG_DIR"), $.env.get("HOME")]);
152 const root = configDirOf(configDir, home);
153
154 return root === undefined ? undefined : themesDirOf(root);
155}
156
157// The folder the saved themes are kept in and their names.
158async function savedThemes($: EngineInterface): Promise<{ folder: string | undefined; names: string[] }> {
159 const folder = await themesFolder($);
160
161 return { folder, names: folder === undefined ? [] : themeNames(await $.fs.list(folder).catch(() => [])) };
162}
163
164// The chosen syntax theme as a file path, empty for the shipped one, with what reads files.
165async function syntaxOf($: EngineInterface): Promise<{ io: SyntaxIo; themePath: string }> {
166 const [preview, chosen] = await Promise.all([read($, syntaxPreviewAtom), read($, syntaxAtom)]);
167 const name = preview ?? chosen;
168 const folder = name === "" ? undefined : await themesFolder($);
169 const io: SyntaxIo = { root: $.plugin.root, read: (path) => $.fs.read(path).catch(() => undefined) };
170
171 return { io, themePath: folder === undefined ? "" : `${folder}/${name}${THEME_EXTENSION}` };
172}
173
174// Highlights code with the syntax theme.
175async function painterFor($: EngineInterface): Promise<Painter> {
176 const { io, themePath } = await syntaxOf($);
177
178 return (hint, code) => highlight(io, themePath, hint, code);
179}
180
181// The fill the syntax theme sets behind a kind of card; undefined when that card has the switch off or the theme sets none.
182async function backgroundFor($: EngineInterface, config: Config, card: Backdrop): Promise<string | undefined> {
183 if (!config.backgrounds[card]) {
184 return undefined;
185 }
186
187 const { io, themePath } = await syntaxOf($);
188
189 return themeBackground(io, themePath);
190}
191
192async function showPane($: EngineInterface, pane: Partial<SyntaxPane>): Promise<void> {
193 await update($, syntaxPaneAtom, (current) => ({ ...current, ...pane }));
194}
195
196// Makes a theme the chosen one, now and in later sessions. Empty chooses the shipped default.
197async function chooseTheme($: EngineInterface, name: string): Promise<void> {
198 await (name === "" ? $.store.delete(SYNTAX_KEY) : $.store.set(SYNTAX_KEY, name));
199 await update($, syntaxAtom, () => name);
200}
201
202// Chooses a theme and closes the picker, which ends the preview.
203async function chooseAndClose($: EngineInterface, name: string): Promise<void> {
204 await chooseTheme($, name);
205 await $.ui.close({ id: PANE_ID });
206}
207
208type Fetched = { ok: boolean; status: number; text: string };
209
210// A text file from the network, with the redirects Open VSX answers file addresses with followed by hand in case the
211// host does not follow them. Undefined when the network cannot be reached.
212async function fetchText($: EngineInterface, url: string): Promise<Fetched | undefined> {
213 let target = url;
214
215 for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
216 const response = await $.http.fetch(target).catch(() => undefined);
217 const location = response?.headers.location;
218
219 if (response === undefined || response.status < 300 || response.status >= 400 || location === undefined) {
220 return response;
221 }
222
223 target = location.startsWith("/") ? `${OPEN_VSX_ORIGIN}${location}` : location;
224
225 if (!target.startsWith("https://")) {
226 return undefined;
227 }
228 }
229
230 return undefined;
231}
232
233// The text of a file inside an extension on Open VSX, or undefined when there is none.
234async function extensionFile($: EngineInterface, extension: ExtensionId, path: string): Promise<string | undefined> {
235 const file = await fetchText($, fileUrl(extension, path));
236
237 return file?.ok ? file.text : undefined;
238}
239
240// Reads the themes an extension on Open VSX holds. One theme is fetched at once; several are offered to choose from.
241async function loadExtension($: EngineInterface, extension: ExtensionId): Promise<void> {
242 const label = `${extension.publisher}.${extension.name}`;
243
244 await showPane($, { message: `Fetching ${label} from Open VSX...` });
245
246 const manifest = await fetchText($, fileUrl(extension, "package.json"));
247
248 if (manifest === undefined) {
249 return showPane($, { message: "Could not reach Open VSX." });
250 }
251
252 if (!manifest.ok) {
253 return showPane($, {
254 message:
255 manifest.status === 404 ? `Open VSX has no extension ${label}.` : `Open VSX answered ${manifest.status}.`,
256 });
257 }
258
259 let themes = themesOf(manifest.text);
260
261 if (needsStrings(themes)) {
262 const strings = await extensionFile($, extension, "package.nls.json");
263
264 themes = themesOf(manifest.text, strings);
265 }
266
267 const [only, ...others] = themes;
268
269 if (only === undefined) {
270 return showPane($, { message: `${label} has no color themes.` });
271 }
272
273 await (others.length === 0
274 ? loadVariant($, extension, only)
275 : showPane($, { step: "variant", extension: label, variants: themes, page: 0, message: undefined }));
276}
277
278// Fetches one theme of an extension on Open VSX, folds in the files it includes, and asks what to call it.
279async function loadVariant($: EngineInterface, extension: ExtensionId, theme: ThemeChoice): Promise<void> {
280 await showPane($, { message: `Fetching ${theme.label}...` });
281
282 const text = await extensionFile($, extension, theme.path);
283
284 if (text === undefined) {
285 return showPane($, { message: `Could not fetch ${theme.label} from Open VSX.` });
286 }
287
288 const content = await flattenTheme(text, theme.path, (path) => extensionFile($, extension, path.slice(1)));
289 const problem = content === undefined ? "That file is not valid JSON." : themeProblem(content);
290
291 await (problem === undefined
292 ? showPane($, { step: "name", path: theme.label, content: content ?? "", message: undefined })
293 : showPane($, { message: problem }));
294}
295
296// First step of adding a theme: an extension on Open VSX, or the file the person named.
297async function readThemeSource($: EngineInterface, input: string): Promise<void> {
298 const extension = parseExtensionId(input);
299
300 await (extension === undefined ? readThemeFile($, input) : loadExtension($, extension));
301}
302
303// The row of a paged list that was chosen: an extension from the popular list, or one theme of an extension.
304async function chooseRow($: EngineInterface, index: number): Promise<void> {
305 const pane = await read($, syntaxPaneAtom);
306
307 if (pane.step === "popular") {
308 const extension = parseExtensionId(POPULAR_THEMES[index]?.id ?? "");
309
310 return extension === undefined ? undefined : loadExtension($, extension);
311 }
312
313 const extension = parseExtensionId(pane.extension);
314 const theme = pane.variants[index];
315
316 return extension === undefined || theme === undefined ? undefined : loadVariant($, extension, theme);
317}
318
319// First step of adding a theme from a file: the file the person named, kept if it reads as a theme.
320async function readThemeFile($: EngineInterface, input: string): Promise<void> {
321 const path = expandHome(input.trim(), await $.env.get("HOME"));
322 const text = path === "" ? undefined : await $.fs.read(path).catch(() => undefined);
323 const problem = text === undefined ? `Could not read ${path === "" ? "an empty path" : path}.` : themeProblem(text);
324
325 await (problem === undefined
326 ? showPane($, { step: "name", path, content: text ?? "", message: undefined })
327 : showPane($, { message: problem }));
328}
329
330// Last step of adding a theme: copies the file into the themes folder under the name asked for, or its own name, with a
331// number added when that name is taken, and chooses it.
332async function importTheme($: EngineInterface, requested: string): Promise<void> {
333 const { path, content } = await read($, syntaxPaneAtom);
334 const folder = await themesFolder($);
335
336 if (folder === undefined) {
337 return showPane($, { step: "list", message: "The Claude folder could not be found." });
338 }
339
340 const taken = themeNames(await $.fs.list(folder).catch(() => []));
341 const name = uniqueName(importName(requested, path), taken);
342
343 try {
344 await $.fs.write(`${folder}/${name}${THEME_EXTENSION}`, content);
345 } catch {
346 return showPane($, { message: `Could not write ${name}${THEME_EXTENSION} to ${folder}.` });
347 }
348
349 await showPane($, LIST_PANE);
350 await chooseAndClose($, name);
351 $.ui.toast(`Added ${name} from ${baseNameOf(path)}.`);
352}
353
354// Whether a block of a reply is drawn as a picture: a table, a heading or a Mermaid fence, unless the settings leave
355// graphics to native drawing.
356function wantsPicture(segment: Segment, config: Config): segment is Exclude<Segment, { kind: "text" }> {
357 return (
358 !config.nativeGraphics &&
359 (segment.kind === "table" || segment.kind === "heading" || (segment.kind === "code" && segment.lang === "mermaid"))
360 );
361}
362
363// The picture a block of a reply is drawn as, or null where it stays text.
364function sketchOf(
365 segment: Exclude<Segment, { kind: "text" }>,
366 available: number,
367 palette: Palette,
368 font: Font,
369): Sketch | null {
370 switch (segment.kind) {
371 case "table":
372 return tableSketch(segment, available, palette, font);
373 case "heading":
374 return headingSketch(segment, available, palette, font);
375 case "code":
376 return diagramSketch(segment.code, available, palette, font);
377 }
378}
379
380// The images as drawn under a row: a picture each where pictures work, else the name and size.
381async function shownImages(
382 $: EngineInterface,
383 found: readonly Found[],
384 config: Config,
385 columns: number,
386 canDraw: boolean,
387): Promise<Shown[]> {
388 const isOff = canDraw ? (await read($, offAtom)) !== null : true;
389 const text = (image: Found): Shown => ({ id: image.id, alt: altOf(image, sizeOfFound(image)), picture: null });
390
391 if (isOff || found.length === 0) {
392 return found.map(text);
393 }
394
395 const font = { family: config.fontFamily, cellHeight: cellHeightOf(config.cellRatio) };
396 const available = Math.min(MAX_COLUMNS, columns - 2 * MARGIN);
397 const dir = await pictureDir($);
398 const io = pictureIo($);
399 const look = `${dir}|${available}|${font.family}|${font.cellHeight}|${config.imageRows}`;
400
401 return Promise.all(
402 found.map(async (image) => {
403 const picture = await pictureFor(
404 io,
405 dir,
406 `${look}|image|${image.id}`,
407 () => imageSketch(image, available, config.imageRows, font.cellHeight, font),
408 font,
409 );
410
411 return picture === null ? text(image) : { id: image.id, alt: picture.alt, picture };
412 }),
413 );
414}
415
416const pasted = new Map<string, Found[]>();
417const MEMORY_PASTED = 20;
418
419// The images pasted into a prompt row, looked up once per row.
420async function pastedOf($: EngineInterface, requestId: string, text: string): Promise<Found[]> {
421 const known = pasted.get(requestId);
422
423 if (known !== undefined) {
424 return known;
425 }
426
427 const found = pastedImagesOf(await $.session.messages({ as: "api" }), text, requestId);
428
429 if (found === null) {
430 return [];
431 }
432
433 pasted.set(requestId, found);
434
435 if (pasted.size > MEMORY_PASTED) {
436 pasted.delete(pasted.keys().next().value as string);
437 }
438
439 return found;
440}
441
442async function pictureDir($: EngineInterface): Promise<string> {
443 return `${((await $.env.get("TMPDIR")) ?? "/tmp").replace(/[\\/]+$/, "")}/veneer-pictures`;
444}
445
446// Creates the picture folder and drops pictures a week old.
447async function prepareFolder($: EngineInterface): Promise<void> {
448 const dir = await pictureDir($);
449
450 await $.process.run(["mkdir", "-p", dir], { timeoutMs: 2000 }).catch(() => null);
451 await $.process
452 .run(["find", dir, "-type", "f", "-name", "*.png", "-mtime", "+7", "-delete"], { timeoutMs: 5000 })
453 .catch(() => null);
454}
455
456// What this terminal cannot draw: icons by the setting and the terminal, links wherever pictures are off.
457async function plainOf($: EngineInterface): Promise<Plain> {
458 const [icons, links] = await Promise.all([read($, plainIconsAtom), read($, plainLinksAtom)]);
459
460 return { icons, links };
461}
462
463async function settle($: EngineInterface, { icons, links }: Pick<Config, "icons" | "links">): Promise<Off | null> {
464 const env = {
465 force: await $.env.get("CLAUDE_CODE_FORCE_TERMINAL_IMAGES"),
466 tmux: await $.env.get("TMUX"),
467 program: await $.env.get("TERM_PROGRAM"),
468 term: await $.env.get("TERM"),
469 kittyWindow: await $.env.get("KITTY_WINDOW_ID"),
470 weztermExecutable: await $.env.get("WEZTERM_EXECUTABLE"),
471 ghosttyResources: await $.env.get("GHOSTTY_RESOURCES_DIR"),
472 };
473 const terminal = terminalOff(env);
474 const probe =
475 terminal === null ? await $.process.run(["resvg", "--version"], { timeoutMs: 2000 }).catch(() => null) : null;
476 const off = terminal ?? (probe?.exitCode === 0 ? null : "resvg");
477
478 await update($, offAtom, () => off);
479 await update($, plainIconsAtom, () => iconsArePlain(icons, env));
480 await update($, plainLinksAtom, () => linksArePlain(links, env));
481
482 const chosen = await $.store.get(SYNTAX_KEY);
483
484 await update($, syntaxAtom, () => (typeof chosen === "string" ? chosen : ""));
485 await $.command
486 .register({ name: PANE_ID, description: "Change Veneer's settings and syntax theme" })
487 .catch(() => null);
488 await $.command.register({ name: SETTINGS_COMMAND, description: "Change Veneer's settings" }).catch(() => null);
489 await $.command.register({ name: SYNTAX_COMMAND, description: "Choose the syntax theme for code" }).catch(() => null);
490 await refreshPalette($);
491
492 if (off === null) {
493 await prepareFolder($);
494 }
495
496 return off;
497}
498
499const configNow = async ($: EngineInterface): Promise<Config> => configOf(await read($, optionsAtom));
500
501// Takes the settings as `/config` holds them, which may be newer than the ones the plugin was registered with.
502async function seedOptions($: EngineInterface, options: PluginOptions): Promise<void> {
503 const rows = await $.config.list().catch(() => []);
504 const current = rows
505 .filter(({ key }) => key.startsWith(SETTINGS_PREFIX))
506 .map(({ key, value }) => [fieldOf(key), value] as const);
507
508 await update($, optionsAtom, () => ({ ...options, ...Object.fromEntries(current) }));
509}
510
511async function settingsOf($: EngineInterface): Promise<Setting[]> {
512 return (await $.config.list()).filter(({ key }) => key.startsWith(SETTINGS_PREFIX));
513}
514
515async function showSettings($: EngineInterface, pane: Partial<SettingsPane>): Promise<void> {
516 await update($, settingsPaneAtom, (current) => ({ ...current, ...pane }));
517}
518
519// Writes one setting the way `/config` does and takes it into effect. The terminal is settled again where the setting
520// decides what it draws with.
521async function writeSetting(
522 $: EngineInterface,
523 setting: Setting,
524 value: Setting["value"],
525): Promise<string | undefined> {
526 const { deny } = await $.config.set({ key: setting.key, value }).catch((error: unknown) => ({ deny: String(error) }));
527
528 if (deny !== undefined) {
529 return `${setting.label} was not changed: ${deny}`;
530 }
531
532 const field = fieldOf(setting.key);
533
534 await update($, optionsAtom, (current) => ({ ...current, [field]: value }));
535
536 if (field === "icons" || field === "links") {
537 await settle($, await configNow($));
538 }
539
540 return undefined;
541}
542
543async function applySetting($: EngineInterface, setting: Setting, value: Setting["value"]): Promise<void> {
544 await showSettings($, { editing: null, message: await writeSetting($, setting, value) });
545}
546
547// `/veneer <setting> <value>`: changes one setting without opening the pane. Bare `/veneer` opens the pane.
548async function runSettingsCommand($: EngineInterface, args: string): Promise<{ text?: string }> {
549 if (args.trim() === "") {
550 return openPane($, "settings");
551 }
552
553 const change = parseSettingChange(await settingsOf($), args);
554
555 if ("error" in change) {
556 return { text: change.error };
557 }
558
559 const failure = await writeSetting($, change.setting, change.value);
560
561 return {
562 text: failure ?? `${change.setting.label} set to ${valueText({ ...change.setting, value: change.value })}.`,
563 };
564}
565
566// A press on a setting: a switch or a choice moves on, and a typed setting turns into a field.
567async function changeSetting($: EngineInterface, setting: Setting): Promise<void> {
568 await (isTyped(setting)
569 ? showSettings($, { editing: setting.key, message: undefined })
570 : applySetting($, setting, nextValue(setting)));
571}
572
573async function submitSetting($: EngineInterface, setting: Setting, text: string): Promise<void> {
574 const value = setting.kind === "number" ? Number(text) : text.trim();
575
576 await (Number.isNaN(value)
577 ? showSettings($, { editing: null, message: `${setting.label} takes a number.` })
578 : applySetting($, setting, value));
579}
580
581// Shows a tab from its first step: no field open, no import half done, no theme previewed.
582async function switchTab($: EngineInterface, tab: Tab): Promise<void> {
583 await update($, tabAtom, () => tab);
584 await showPane($, LIST_PANE);
585 await showSettings($, CLOSED_SETTINGS);
586 await update($, syntaxPreviewAtom, () => null);
587}
588
589async function openPane($: EngineInterface, tab: Tab): Promise<{ text?: string }> {
590 await switchTab($, tab);
591
592 const opened = await $.ui.open({
593 id: PANE_ID,
594 title: "Veneer",
595 focus: true,
596 closeOnEscape: true,
597 rows: PANE_ROWS,
598 columns: PANE_COLUMNS,
599 });
600
601 return opened.isPlaced ? {} : { text: "The Veneer pane could not be shown: " + opened.reason };
602}
603
604export const register: Register = (on, options) => {
605 on("session.start", async ($, e, next) => {
606 await seedOptions($, options);
607
608 const off = await settle($, await configNow($));
609
610 if (off !== null) {
611 $.ui.toast(OFF_TEXT[off], { timeoutMs: NOTICE_MS });
612 }
613
614 return next(e);
615 });
616
617 // /clear, /resume and /branch reset the session's state and skip session.start.
618 on("classic.SessionStart", { source: ["clear", "resume", "fork"] }, async ($, e, next) => {
619 await seedOptions($, options);
620 await settle($, await configNow($));
621
622 return next(e);
623 });
624
625 on("prompt.compose", async ($, e, next) => {
626 const result = await next(e);
627 const isHeadless = e.surfaces.length === 0 || e.traits.includes("bare") || e.traits.includes("print");
628
629 return isHeadless || !(await configNow($)).diagramHint
630 ? result
631 : { sections: [...result.sections, { id: "veneer:diagrams", text: MERMAID_HINT, scope: "session" as const }] };
632 });
633
634 on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
635 const { text, origin } = e.props;
636 const config = await configNow($);
637 const hasImages = config.images && text.includes(PASTED_MARKER);
638
639 if (
640 e.surface !== "terminal" ||
641 (!config.styleUserMessage && !hasImages) ||
642 origin.kind !== "composer" ||
643 text.trim() === ""
644 ) {
645 return next(e);
646 }
647
648 const native = await next(e);
649
650 if (native.type !== "engine") {
651 return native;
652 }
653
654 const ui = $.ui.resolve(e);
655 const columns = e.viewport?.columns ?? 100;
656 const withImages = async (row: RenderElement): Promise<RenderElement> => {
657 if (!hasImages) {
658 return row;
659 }
660
661 const { Box } = ui;
662 const shown = await shownImages($, await pastedOf($, e.requestId, text), config, columns, "Image" in ui);
663
664 return (
665 <Box flexDirection="column">
666 {row}
667 {imageColumn(ui, shown, MARGIN, config.align === "right" ? "flex-end" : "flex-start")}
668 </Box>
669 );
670 };
671
672 if (!config.styleUserMessage) {
673 return withImages(native);
674 }
675
676 const copy = {
677 scope: `bubble:${e.requestId}`,
678 isPlain: await read($, plainIconsAtom),
679 onCopy: async () => {
680 const { isCopied } = await $.ui.copy({ text, surface: e.surface });
681
682 $.ui.toast(isCopied ? "Message copied" : "Copy failed");
683 },
684 };
685
686 return withImages(userBubble(ui, text, columns, config.align, copy));
687 });
688
689 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
690 const native = await next(e);
691 const { text, isFirstOfReply, isSummary } = e.props;
692
693 // A tree another mod returned is theirs; only core's own drawing is replaced.
694 if (e.surface !== "terminal" || native.type !== "engine" || isSummary === true || text.trim() === "") {
695 return native;
696 }
697
698 const ui = $.ui.resolve(e);
699 const segments = splitReply(text);
700 const bullet = isFirstOfReply ? BULLET : null;
701 const hasPictures = "Image" in ui && hasDrawn(segments);
702 const hasCode = segments.some((segment) => segment.kind === "code");
703 const [config, isPlain, off] = await Promise.all([
704 configNow($),
705 read($, plainIconsAtom),
706 hasPictures ? read($, offAtom) : null,
707 ]);
708 const isPictured = hasPictures && off === null;
709 const [fences, fenceBackground, palette, dir] = await Promise.all([
710 hasCode ? painterFor($).then((paint) => paintFences(paint, segments)) : undefined,
711 hasCode ? backgroundFor($, config, "fences") : undefined,
712 isPictured ? read($, paletteAtom) : undefined,
713 isPictured ? pictureDir($) : undefined,
714 ]);
715 const copyFor = (index: number, source: string, noun: string) => ({
716 scope: `copy:${e.requestId}:${index}`,
717 isPlain,
718 onCopy: async () => {
719 const { isCopied } = await $.ui.copy({ text: source, surface: e.surface });
720
721 $.ui.toast(isCopied ? `${noun} copied` : "Copy failed");
722 },
723 });
724 const drawn = new Map<Segment, { picture: Picture; alt: string }>();
725 const width = (e.viewport?.columns ?? 100) - GUTTER - 2 * MARGIN;
726 const diagramOf = (source: string) =>
727 diagramText(source, { columns: width - CARD_COLUMNS, ratio: config.cellRatio });
728
729 if (!isPictured || palette === undefined || dir === undefined) {
730 return replyTree(ui, segments, drawn, bullet, fences, fenceBackground, copyFor, width, diagramOf);
731 }
732
733 const font = { family: config.fontFamily, cellHeight: cellHeightOf(config.cellRatio) };
734 const available = Math.min(MAX_COLUMNS, width);
735 const io = pictureIo($);
736 const look = `${dir}|${available}|${font.family}|${font.cellHeight}|${hashOf(JSON.stringify(palette))}`;
737
738 await Promise.all(
739 segments.map(async (segment) => {
740 if (!wantsPicture(segment, config)) {
741 return;
742 }
743
744 const picture = await pictureFor(
745 io,
746 dir,
747 `${look}|${segment.kind}|${segment.raw}`,
748 () => sketchOf(segment, available, palette, font),
749 font,
750 );
751
752 if (picture !== null) {
753 drawn.set(segment, { picture, alt: picture.alt });
754 }
755 }),
756 );
757
758 return replyTree(ui, segments, drawn, bullet, fences, fenceBackground, copyFor, width, diagramOf);
759 });
760
761 on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
762 if (e.props.tool === "Bash" || e.props.tool === "Read") {
763 rememberInput(e.props.tool_use_id, e.props.input);
764 }
765
766 const native = await next(e);
767 const { isRunning, isErrored, output } = e.props;
768
769 if (e.surface !== "terminal" || native.type !== "engine") {
770 return native;
771 }
772
773 const ui = $.ui.resolve(e);
774 const changes = !isRunning && !isErrored ? changeCounts(output) : null;
775 const [commandRuns, plain, cwd, isAnsi] = await Promise.all([
776 e.props.tool === "Bash" ? painterFor($).then((paint) => paintCommand(paint, e.props)) : undefined,
777 plainOf($),
778 $.session.cwd(),
779 changes === null ? undefined : read($, paletteAtom).then((palette) => palette.isAnsi),
780 ]);
781 const row = toolRow(ui, e.props, cwd, commandRuns, plain) ?? native;
782
783 return changes === null || isAnsi === undefined ? row : withBadges(ui, row, changes, isAnsi, plain.icons);
784 });
785
786 on("ui.render", { component: "ToolResult" }, async ($, e, next) => {
787 const native = await next(e);
788
789 if (e.surface !== "terminal" || native.type !== "engine" || e.props.isErrored) {
790 return native;
791 }
792
793 const found = imagesOf(e.props, pathOfCall(e.props.tool_use_id));
794
795 if (found.length > 0) {
796 const config = await configNow($);
797
798 if (!config.images) {
799 return native;
800 }
801
802 const ui = $.ui.resolve(e);
803 const { Box } = ui;
804 const shown = await shownImages($, found, config, e.viewport?.columns ?? 100, "Image" in ui);
805
806 return (
807 <Box flexDirection="column">
808 {native}
809 {imageColumn(ui, shown, MARGIN)}
810 </Box>
811 );
812 }
813
814 const { tool, output } = e.props;
815 const diff = parseDiff(output);
816 const lines = diff === null ? parseOutput(tool, output, await $.session.cwd()) : null;
817
818 if (diff === null && lines === null) {
819 return native;
820 }
821
822 const id = e.props.tool_use_id;
823 const path = tool === "Read" || diff !== null ? filePathOf(output) : undefined;
824 const [config, open, paint, palette] = await Promise.all([
825 configNow($),
826 read($, expandedAtom),
827 path !== undefined || tool === "Bash" || tool === "Grep" ? painterFor($) : undefined,
828 diff === null ? undefined : read($, paletteAtom),
829 ]);
830 const expanded = open.includes(id);
831 const toggle = () => flip($, id);
832 const ui = $.ui.resolve(e);
833 // A folded card shows its first lines only, so only those are tokenized; unfolding paints the rest.
834 const visible = expanded ? Infinity : MAX_LINES;
835
836 if (diff === null) {
837 const shown = lines ?? [];
838
839 if (paint === undefined) {
840 return outputCard(ui, shown, expanded, toggle, id);
841 }
842
843 const [painted, background] = await Promise.all([
844 path === undefined
845 ? paintOutput(paint, tool, commandOfCall(id), shown)
846 : paintLines(paint, path, shown.slice(0, visible)).then((head) => [...head, ...shown.slice(visible)]),
847 backgroundFor($, config, path === undefined ? "output" : "reads"),
848 ]);
849
850 return outputCard(ui, painted, expanded, toggle, id, outputFill(background, path !== undefined, painted));
851 }
852
853 const [runs, background] = await Promise.all([
854 path === undefined || paint === undefined
855 ? []
856 : paintDiff(paint, path, { ...diff, lines: diff.lines.slice(0, visible) }),
857 backgroundFor($, config, "diffs"),
858 ]);
859
860 return diffCard(ui, diff, palette?.isAnsi ?? false, expanded, toggle, id, runs, background);
861 });
862
863 on("ui.render", { component: "Spinner" }, async ($, e, next) => {
864 const native = await next(e);
865
866 if (e.surface !== "terminal" || !(await configNow($)).styleSpinner || native.type !== "engine") {
867 return native;
868 }
869
870 const { Box } = $.ui.resolve(e);
871
872 return (
873 <Box marginLeft={MARGIN} marginRight={MARGIN}>
874 {native}
875 </Box>
876 );
877 });
878
879 on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
880 const native = await next(e);
881
882 if (e.surface !== "terminal" || native.type !== "engine" || e.props.isExpanded) {
883 return native;
884 }
885
886 const [config, expandedIds, cwd] = await Promise.all([configNow($), read($, expandedAtom), $.session.cwd()]);
887 const open = new Set(expandedIds);
888 const { calls } = e.props;
889 const showsRows = calls.length === 1 || open.has(`group:${e.requestId}`);
890 const paint =
891 showsRows && calls.some((call) => call.tool === "Bash" || call.tool === "Grep") ? await painterFor($) : undefined;
892 const commandRuns = await Promise.all(
893 calls.map((call) => (paint === undefined ? undefined : paintCommand(paint, call))),
894 );
895 const paintedOutput = await Promise.all(
896 calls.map(async (call) => {
897 const lines =
898 paint === undefined || call.isRunning ? null : parseOutput(call.tool, call.output, cwd, call.isErrored);
899 const command = (call.input as { command?: unknown } | null | undefined)?.command;
900
901 return paint === undefined || lines === null || call.isErrored
902 ? undefined
903 : paintOutput(paint, call.tool, typeof command === "string" ? command : undefined, lines);
904 }),
905 );
906
907 const ui = $.ui.resolve(e);
908 const columns = e.viewport?.columns ?? 100;
909 const [reads, output, plain, images] = await Promise.all([
910 paint === undefined ? undefined : backgroundFor($, config, "reads"),
911 paint === undefined ? undefined : backgroundFor($, config, "output"),
912 plainOf($),
913 Promise.all(
914 calls.map(async (call) => {
915 const path = (call.input as { file_path?: unknown } | null | undefined)?.file_path;
916 const found = showsRows && config.images ? imagesOf(call, typeof path === "string" ? path : undefined) : [];
917
918 return imageColumn(ui, await shownImages($, found, config, columns, "Image" in ui), 2);
919 }),
920 ),
921 ]);
922
923 return toolGroup(
924 ui,
925 calls,
926 e.requestId,
927 cwd,
928 { open, toggle: (id) => flip($, id) },
929 commandRuns,
930 paintedOutput,
931 paint === undefined ? {} : { reads, output },
932 plain,
933 images,
934 );
935 });
936
937 on("command.run", { command: PANE_ID }, ($, e) => runSettingsCommand($, e.args));
938
939 on("command.run", { command: SETTINGS_COMMAND }, ($, e) => runSettingsCommand($, e.args));
940
941 on("command.run", { command: SYNTAX_COMMAND }, ($) => openPane($, "syntax"));
942
943 // The picker previews the theme its focus is on; the preview ends with the pane, however it closes. Moving the focus
944 // off a field puts the row it replaced back.
945 on("ui.focus", { component: "Pane", requestId: PANE_ID }, async ($, e, next) => {
946 const result = await next(e);
947 const value = e.element?.startsWith(THEME_KEY) ? e.element.slice(THEME_KEY.length) : undefined;
948
949 if (result.deny === undefined) {
950 await update($, focusAtom, () => e.element ?? null);
951
952 const { editing } = await read($, settingsPaneAtom);
953
954 if (e.element !== undefined && editing !== null && e.element !== `${SETTING_KEY}${editing}`) {
955 await showSettings($, { editing: null });
956 }
957
958 if (e.element !== undefined && e.element !== PATH_KEY && (await read($, syntaxPaneAtom)).step === "path") {
959 await showPane($, LIST_PANE);
960 }
961
962 await update($, syntaxPreviewAtom, () =>
963 value === undefined || value === NEW_THEME || value === POPULAR_THEME
964 ? null
965 : value === DEFAULT_THEME
966 ? ""
967 : value,
968 );
969 }
970
971 return result;
972 });
973
974 on("ui.close", { id: PANE_ID }, async ($, e, next) => {
975 await update($, syntaxPreviewAtom, () => null);
976 await update($, focusAtom, () => null);
977 await showSettings($, CLOSED_SETTINGS);
978
979 return next(e);
980 });
981
982 // Where the pane is taller than its room the engine scrolls it on an arrow key and the focus stays put. A single-row
983 // move by the keys walks the focus instead, which brings its element into view; past either end, and for the wheel and
984 // the page keys, the pane scrolls as before.
985 on("ui.scroll", { requestId: PANE_ID }, async ($, e, next) => {
986 if (e.origin.kind !== "person" || e.pointer !== undefined || Math.abs(e.by) !== 1) {
987 return next(e);
988 }
989
990 const rows =
991 (await read($, tabAtom)) === "settings"
992 ? settingsKeys(groupSettings(await settingsOf($)))
993 : paneKeys(await read($, syntaxPaneAtom), (await savedThemes($)).names, (await themesFolder($)) !== undefined);
994 const keys = [...tabKeys, ...rows];
995 const focused = await read($, focusAtom);
996 const target = keys[(focused === null ? -1 : keys.indexOf(focused)) + e.by];
997
998 if (target === undefined) {
999 return next(e);
1000 }
1001
1002 await $.ui.focus({ requestId: PANE_ID, key: target }).catch(() => null);
1003
1004 return {};
1005 });
1006
1007 on("ui.render", { component: "Pane", requestId: PANE_ID }, async ($, e, next) => {
1008 if (e.surface !== "terminal") {
1009 return next(e);
1010 }
1011
1012 const ui = $.ui.resolve(e);
1013 const tab = await read($, tabAtom);
1014 const select = (chosen: Tab) => void switchTab($, chosen);
1015
1016 if (tab === "settings") {
1017 // Read for its dependency: the pane draws again when a setting changes.
1018 await read($, optionsAtom);
1019
1020 const { editing, message } = await read($, settingsPaneAtom);
1021
1022 return tabbed(
1023 ui,
1024 tab,
1025 select,
1026 settingsPane(
1027 ui,
1028 {
1029 groups: groupSettings(await settingsOf($)),
1030 editing,
1031 focused: (await read($, focusAtom))?.slice(SETTING_KEY.length) ?? null,
1032 message,
1033 },
1034 {
1035 change: (setting) => void changeSetting($, setting),
1036 submit: (setting, text) => void submitSetting($, setting, text),
1037 },
1038 ),
1039 );
1040 }
1041
1042 const config = await configNow($);
1043 const { folder, names } = await savedThemes($);
1044 const active = await read($, syntaxAtom);
1045 const paint = await painterFor($);
1046 const { io } = await syntaxOf($);
1047 const swatches = Object.fromEntries(
1048 await Promise.all(
1049 [DEFAULT_THEME, ...names].map(async (value) => [
1050 value,
1051 await themeSwatch(
1052 io,
1053 value === DEFAULT_THEME || folder === undefined ? "" : `${folder}/${value}${THEME_EXTENSION}`,
1054 ),
1055 ]),
1056 ),
1057 );
1058
1059 return tabbed(
1060 ui,
1061 tab,
1062 select,
1063 syntaxPane(
1064 ui,
1065 {
1066 pane: await read($, syntaxPaneAtom),
1067 names,
1068 swatches,
1069 active,
1070 folder,
1071 samples: await Promise.all(SAMPLES.map(async ({ hint, code }) => (await paint(hint, code)) ?? undefined)),
1072 background: await backgroundFor($, config, "fences"),
1073 },
1074 {
1075 pick: (value) => {
1076 if (value === NEW_THEME) {
1077 void showPane($, { step: "path", message: undefined });
1078 } else if (value === POPULAR_THEME) {
1079 void showPane($, { step: "popular", page: 0, message: undefined });
1080 } else {
1081 void chooseAndClose($, value === DEFAULT_THEME ? "" : value);
1082 }
1083 },
1084 submitPath: (path) => void readThemeSource($, path),
1085 submitName: (name) => void importTheme($, name),
1086 choose: (index) => void chooseRow($, index),
1087 turnPage: (by) => void update($, syntaxPaneAtom, (current) => ({ ...current, page: current.page + by })),
1088 back: () => void showPane($, LIST_PANE),
1089 },
1090 ),
1091 );
1092 });
1093
1094 on("config.set", async ($, e, next) => {
1095 const result = await next(e);
1096
1097 if (e.key === "theme" && result.deny === undefined) {
1098 await refreshPalette($, result.value);
1099 }
1100
1101 if (e.key.startsWith(SETTINGS_PREFIX) && result.deny === undefined) {
1102 const field = fieldOf(e.key);
1103
1104 await update($, optionsAtom, (current) => ({ ...current, [field]: result.value }));
1105
1106 if (field === "icons" || field === "links") {
1107 await settle($, await configNow($));
1108 }
1109 }
1110
1111 return result;
1112 });
1113};
1114hooks/config.ts 78 lines1import type { PluginOptions } from "claude-code";
2
3export type Config = {
4 align: "left" | "right";
5 fontFamily: string;
6 cellRatio: number;
7 backgrounds: Record<Backdrop, boolean>;
8 icons: "auto" | "nerd" | "plain";
9 links: "auto" | "link" | "text";
10 nativeGraphics: boolean;
11 images: boolean;
12 imageRows: number;
13 diagramHint: boolean;
14 styleUserMessage: boolean;
15 styleSpinner: boolean;
16};
17
18// The cards that may take the syntax theme's `editor.background`.
19export type Backdrop = "fences" | "reads" | "output" | "diffs";
20
21const BACKDROP_OPTIONS = {
22 fences: "fenceBackground",
23 reads: "readBackground",
24 output: "outputBackground",
25 diffs: "diffBackground",
26} as const satisfies Record<Backdrop, string>;
27
28// A switch that is on unless set to false, as a boolean or as the text a settings file may hold.
29const onUnlessFalse = (value: unknown): boolean => value !== false && value !== "false";
30
31// Tried in order after the configured font, so a blank or unknown name still draws in a sans-serif face.
32const FALLBACK_FONTS = [
33 "Arial",
34 "Helvetica Neue",
35 "Helvetica",
36 "Segoe UI",
37 "Liberation Sans",
38 "DejaVu Sans",
39 "Noto Sans",
40 "Roboto",
41];
42const DEFAULT_RATIO = 2.9;
43
44// A terminal cell's height over its width; anything outside what real terminals use keeps the default.
45const ratioOf = (value: unknown): number => {
46 const ratio = Number(value);
47
48 return ratio >= 1 && ratio <= 6 ? ratio : DEFAULT_RATIO;
49};
50
51const DEFAULT_IMAGE_ROWS = 20;
52
53// The tallest an image draws, in rows; anything outside 2 to 250 keeps the default.
54const imageRowsOf = (value: unknown): number => {
55 const rows = Math.floor(Number(value));
56
57 return rows >= 2 && rows <= 250 ? rows : DEFAULT_IMAGE_ROWS;
58};
59
60export const configOf = (options: PluginOptions): Config => ({
61 align: options.messageAlign === "right" ? "right" : "left",
62 fontFamily: [typeof options.fontFamily === "string" ? options.fontFamily.trim() : "", ...FALLBACK_FONTS]
63 .filter((font) => font !== "")
64 .join(", "),
65 cellRatio: ratioOf(options.cellRatio),
66 backgrounds: Object.fromEntries(
67 Object.entries(BACKDROP_OPTIONS).map(([card, option]) => [card, onUnlessFalse(options[option])]),
68 ) as Record<Backdrop, boolean>,
69 icons: options.icons === "nerd" || options.icons === "plain" ? options.icons : "auto",
70 links: options.links === "link" || options.links === "text" ? options.links : "auto",
71 nativeGraphics: options.nativeGraphics === true || options.nativeGraphics === "true",
72 images: onUnlessFalse(options.images),
73 imageRows: imageRowsOf(options.imageRows),
74 diagramHint: onUnlessFalse(options.diagramHint),
75 styleUserMessage: onUnlessFalse(options.styleUserMessage),
76 styleSpinner: onUnlessFalse(options.styleSpinner),
77});
78hooks/gate.ts 70 lines1import type { Off } from "../types";
2
3// The variables that say what the terminal is; each read where `$.env.get` can name it.
4export type TerminalEnv = {
5 force: string | undefined;
6 tmux: string | undefined;
7 program: string | undefined;
8 term: string | undefined;
9 kittyWindow: string | undefined;
10 weztermExecutable: string | undefined;
11 ghosttyResources: string | undefined;
12};
13
14export const OFF_TEXT: Record<Off, string> = {
15 tmux: "Images are not supported in this tmux session. Falling back to text replacements.",
16 terminal: "Images are not supported in this session. Falling back to text replacements.",
17 resvg: "Images need resvg (brew install resvg). Falling back to text replacements.",
18};
19
20// Terminals that carry their own symbol font, by `TERM_PROGRAM` and by `TERM`.
21const SYMBOL_PROGRAMS = new Set(["ghostty", "WezTerm", "rio"]);
22const SYMBOL_TERMS = new Set(["xterm-ghostty", "xterm-kitty", "wezterm", "rio"]);
23
24// Claude Code draws pictures with kitty Unicode placeholders and turns them on only in kitty and Ghostty. WezTerm, Rio
25// and Warp show an image's alt text instead.
26const PICTURE_PROGRAMS = new Set(["ghostty"]);
27const PICTURE_TERMS = new Set(["xterm-ghostty", "xterm-kitty"]);
28
29// Pictures are drawn in kitty and Ghostty, and not inside a multiplexer unless forced.
30export function terminalOff(env: TerminalEnv): Off | null {
31 if (env.force === "1") {
32 return null;
33 }
34
35 if (env.tmux) {
36 return "tmux";
37 }
38
39 const draws =
40 PICTURE_PROGRAMS.has(env.program ?? "") ||
41 PICTURE_TERMS.has(env.term ?? "") ||
42 env.kittyWindow !== undefined ||
43 env.ghosttyResources !== undefined;
44
45 return draws ? null : "terminal";
46}
47
48// Terminals that carry their own symbol font, so the Nerd Font icons draw with nothing installed. Ghostty's resources
49// folder variable, like the kitty and WezTerm ones, outlives a multiplexer that rewrites `TERM_PROGRAM` and `TERM`.
50export const hasSymbolFont = (env: TerminalEnv): boolean =>
51 SYMBOL_PROGRAMS.has(env.program ?? "") ||
52 SYMBOL_TERMS.has(env.term ?? "") ||
53 env.kittyWindow !== undefined ||
54 env.weztermExecutable !== undefined ||
55 env.ghosttyResources !== undefined;
56
57// Terminals where a file path is shown in full as plain text and not as a link. Claude Code prints the address beside the
58// text of a link in a terminal it does not know to open links. Rio finds the path in the text and opens it itself, and
59// Terminal.app opens neither a link nor a path.
60const PLAIN_LINK_PROGRAMS = new Set(["rio", "Apple_Terminal"]);
61
62// Whether a file path is plain text: always when the setting is `text`, never when it is `link`, and by the terminal when
63// it is `auto`.
64export const linksArePlain = (setting: "auto" | "link" | "text", env: TerminalEnv): boolean =>
65 setting === "text" || (setting === "auto" && (PLAIN_LINK_PROGRAMS.has(env.program ?? "") || env.term === "rio"));
66
67// The icons to draw: Nerd Font ones when the setting is `nerd`, or `auto` in a terminal with its own symbol font.
68export const iconsArePlain = (setting: "auto" | "nerd" | "plain", env: TerminalEnv): boolean =>
69 setting === "plain" || (setting === "auto" && !hasSymbolFont(env));
70hooks/badge.tsx 62 lines1import type { ElementTable, RenderElement } from "claude-code";
2
3import { hunksOf } from "./diff";
4
5type Ui = Pick<ElementTable, "Box" | "Text">;
6
7export type Changes = { added: number; removed: number };
8
9const ANSI_TEXT = "#000000";
10
11// Powerline half circles, which Ghostty and kitty draw without any font installed.
12const LEFT_CAP = "";
13const RIGHT_CAP = "";
14
15// Added and removed line counts from an edit's stored result, or null when it holds no change.
16export function changeCounts(output: unknown): Changes | null {
17 const patches = hunksOf(output);
18
19 if (patches === null) {
20 return null;
21 }
22
23 const lines = patches.flatMap((patch) => (Array.isArray(patch?.lines) ? (patch.lines as unknown[]) : []));
24 const added = lines.filter((line) => typeof line === "string" && line.startsWith("+")).length;
25 const removed = lines.filter((line) => typeof line === "string" && line.startsWith("-")).length;
26
27 return added + removed === 0 ? null : { added, removed };
28}
29
30// The native row with a pill for the lines added and one for the lines removed, on the row's own first line. Fills and
31// text are theme tokens, so a pill matches Claude Code's own diff on every theme, colour-blind ones included. On the
32// ansi themes the success and error colours are the diff fills' own, so the text is a fixed black, which the terminal's
33// own palette cannot wash out as it does white on those fills. In plain mode, for terminals that do not draw Powerline
34// glyphs, a pill is a filled block with square ends.
35export function withBadges(
36 { Box, Text }: Ui,
37 native: RenderElement,
38 changes: Changes,
39 isAnsi: boolean,
40 isPlain = false,
41): RenderElement {
42 const plain = isAnsi ? ANSI_TEXT : null;
43
44 const pill = (label: string, fill: string, color: string) => (
45 <Text key={label}>
46 {isPlain ? null : <Text color={fill}>{LEFT_CAP}</Text>}
47 <Text color={color} backgroundColor={fill} bold>{` ${label} `}</Text>
48 {isPlain ? null : <Text color={fill}>{RIGHT_CAP}</Text>}
49 </Text>
50 );
51
52 return (
53 <Box flexDirection="row">
54 <Box flexShrink={1}>{native}</Box>
55 <Box flexShrink={0} marginTop={1} marginLeft={1} marginRight={1} gap={1}>
56 {changes.added > 0 ? pill(`+${changes.added}`, "diffAdded", plain ?? "success") : null}
57 {changes.removed > 0 ? pill(`-${changes.removed}`, "diffRemoved", plain ?? "error") : null}
58 </Box>
59 </Box>
60 );
61}
62hooks/diff.tsx 135 lines1import type { ElementTable, RenderElement } from "claude-code";
2
3import { foldButton, MAX_LINES, TAB } from "./fold";
4import { MARGIN } from "./reply";
5import { lineChildren } from "./syntax/runs";
6import type { StyledRun } from "./syntax/types";
7
8type Ui = Pick<ElementTable, "Box" | "Text" | "Button">;
9
10export type DiffLine = {
11 kind: "add" | "remove" | "context";
12 text: string;
13 oldNumber: number | null;
14 newNumber: number | null;
15};
16
17export type Diff = { lines: DiffLine[]; breaks: ReadonlySet<number>; created?: boolean };
18
19type Hunk = { oldStart?: number; newStart?: number; lines?: unknown };
20
21// The hunks of an edit's stored result. A newly created file stores no patch, only its content, so that becomes one hunk
22// of added lines.
23export function hunksOf(output: unknown): Hunk[] | null {
24 const result = output as { type?: unknown; content?: unknown; structuredPatch?: unknown } | null | undefined;
25
26 if (Array.isArray(result?.structuredPatch) && result.structuredPatch.length > 0) {
27 return result.structuredPatch as Hunk[];
28 }
29
30 if (result?.type === "create" && typeof result.content === "string" && result.content !== "") {
31 const lines = result.content.replace(/\n$/, "").split("\n");
32
33 return [{ oldStart: 0, newStart: 1, lines: lines.map((line) => `+${line}`) }];
34 }
35
36 return null;
37}
38
39// The stored patch of an edit as numbered lines, with the indexes where a new hunk starts. Null when it holds none.
40export function parseDiff(output: unknown): Diff | null {
41 const patch = hunksOf(output);
42
43 if (patch === null) {
44 return null;
45 }
46
47 const lines: DiffLine[] = [];
48 const breaks = new Set<number>();
49
50 for (const hunk of patch) {
51 let oldNumber = hunk.oldStart ?? 1;
52 let newNumber = hunk.newStart ?? 1;
53
54 if (lines.length > 0) {
55 breaks.add(lines.length);
56 }
57
58 for (const raw of Array.isArray(hunk.lines) ? hunk.lines : []) {
59 if (typeof raw !== "string") {
60 continue;
61 }
62
63 const text = raw.slice(1).replace(/\t/g, TAB);
64
65 if (raw.startsWith("+")) {
66 lines.push({ kind: "add", text, oldNumber: null, newNumber: newNumber++ });
67 } else if (raw.startsWith("-")) {
68 lines.push({ kind: "remove", text, oldNumber: oldNumber++, newNumber: null });
69 } else {
70 lines.push({ kind: "context", text, oldNumber: oldNumber++, newNumber: newNumber++ });
71 }
72 }
73 }
74
75 const created = (output as { type?: unknown } | null | undefined)?.type === "create";
76
77 return lines.some((line) => line.kind !== "context") ? { lines, breaks, created } : null;
78}
79
80// The edit as a rounded card of numbered lines. A changed line's code is filled with the theme's diff token, so it matches
81// Claude Code's own diff on every theme, and the fill starts right of its number. Like Claude Code's own diff there is
82// one number column, coloured on changed lines in the line's own fill colour, as the badges are filled: the line's
83// new number, or its old one where it was removed. The ansi themes draw the diff without fills, so there the numbers
84// and text take the success and error colours. A background fills the card under the lines; changed lines keep their fill.
85export function diffCard(
86 ui: Ui,
87 diff: Diff,
88 isAnsi: boolean,
89 expanded: boolean,
90 toggle: () => void,
91 id: string,
92 runs: readonly (StyledRun[] | undefined)[] = [],
93 background?: string,
94): RenderElement {
95 const { Box, Text } = ui;
96 const shown = expanded ? diff.lines : diff.lines.slice(0, MAX_LINES);
97 const numberOf = (line: DiffLine) => line.newNumber ?? line.oldNumber;
98 const digits = Math.max(1, ...shown.map((line) => String(numberOf(line) ?? "").length));
99 const plainLines = diff.created === true;
100 const tone = (kind: DiffLine["kind"]) =>
101 plainLines ? undefined : kind === "add" ? "success" : kind === "remove" ? "error" : undefined;
102 const fillOf = (kind: DiffLine["kind"]) =>
103 isAnsi || plainLines ? undefined : kind === "add" ? "diffAdded" : kind === "remove" ? "diffRemoved" : undefined;
104 const numberTone = (kind: DiffLine["kind"]) => (isAnsi ? tone(kind) : fillOf(kind));
105
106 return (
107 <Box
108 flexDirection="column"
109 marginLeft={MARGIN}
110 marginRight={MARGIN}
111 borderStyle="round"
112 borderColor="subtle"
113 backgroundColor={background}
114 >
115 {shown.map((line, i) => (
116 <Box key={`line-${i}`} flexDirection="column">
117 {diff.breaks.has(i) ? <Text dimColor>{`${" ".repeat(digits + 2)}⋯`}</Text> : null}
118 <Box flexDirection="row">
119 <Text
120 color={numberTone(line.kind)}
121 dimColor={line.kind === "context" || plainLines}
122 >{` ${String(numberOf(line) ?? "").padStart(digits)} `}</Text>
123 <Box flexGrow={1} flexShrink={1} paddingLeft={1} backgroundColor={fillOf(line.kind)}>
124 <Text color={isAnsi ? tone(line.kind) : undefined} wrap="wrap">
125 {lineChildren(ui, line.text, runs[i])}
126 </Text>
127 </Box>
128 </Box>
129 </Box>
130 ))}
131 {foldButton(ui, diff.lines.length, expanded, toggle, id)}
132 </Box>
133 );
134}
135hooks/fold.tsx 32 lines1import type { ElementTable, RenderElement } from "claude-code";
2
3type Ui = Pick<ElementTable, "Box" | "Button" | "Text">;
4
5// Lines drawn before the rest is folded into a count.
6export const MAX_LINES = 24;
7export const TAB = " ";
8
9// The line under a folded card that shows the rest when pressed, and folds it again. Null when everything fits. The id
10// keeps the key of one card's button apart from another's in the same tree.
11export function foldButton(
12 { Box, Button, Text }: Ui,
13 total: number,
14 expanded: boolean,
15 toggle: () => void,
16 id: string,
17) {
18 if (total <= MAX_LINES) {
19 return null;
20 }
21
22 return (
23 <Box paddingLeft={1}>
24 <Button key={`fold:${id}`} plain onPress={toggle}>
25 <Text color="claude">
26 {expanded ? "Show fewer lines" : `Show ${total - MAX_LINES} more line${total - MAX_LINES === 1 ? "" : "s"}`}
27 </Text>
28 </Button>
29 </Box>
30 ) as RenderElement;
31}
32hooks/group.tsx 158 lines1import type { ElementTable, RenderElement } from "claude-code";
2
3import { outputCard, parseOutput } from "./output";
4import type { OutputLine } from "./output";
5import { outputFill } from "./paint";
6import { MARGIN } from "./reply";
7import { lineChildren } from "./syntax/runs";
8import type { StyledRun } from "./syntax/types";
9import type { Look, Plain, ToolCall } from "./tool-row";
10import { inputOf, lookFor, NOT_PLAIN, pathHover, pathOf, shortPath, toneOf } from "./tool-row";
11
12type Ui = Pick<ElementTable, "Box" | "Button" | "Link" | "Text">;
13
14export type GroupCall = ToolCall;
15
16// Which entries are open, by id, and the press that flips one.
17export type Folds = { open: ReadonlySet<string>; toggle: (id: string) => void };
18
19type Tone = ReturnType<typeof toneOf>;
20type Segment = { look: Look; count: number; tone: Tone };
21
22const SEVERITY: readonly Tone[] = ["success", "suggestion", "error"];
23
24// The calls as one segment per tool name, in the order each first appears. A segment takes the worst colour of its calls:
25// the error colour if one failed, else the running colour if one still runs.
26function segmentsOf(calls: readonly GroupCall[], isPlain: boolean): Segment[] {
27 const found = new Map<string, Segment>();
28
29 for (const call of calls) {
30 const look = lookFor(call.tool, isPlain);
31 const segment = found.get(look.label) ?? { look, count: 0, tone: "success" as Tone };
32 const tone = toneOf(call);
33
34 found.set(look.label, {
35 ...segment,
36 count: segment.count + 1,
37 tone: SEVERITY.indexOf(tone) > SEVERITY.indexOf(segment.tone) ? tone : segment.tone,
38 });
39 }
40
41 return [...found.values()];
42}
43
44// A run of finished calls as one line of counts, `Command 2 Read 3`, which opens into a row for each call, and each row
45// that has output opens into its card. A run of one call has nothing to count, so it is drawn as its row. The whole line takes the text colour while the pointer is on it. A path stays a
46// link beside its row's button, so a click on it opens the file and a click on the rest folds the row. The images a
47// call returned, drawn by the caller, sit under its row.
48export function toolGroup(
49 { Box, Button, Link, Text }: Ui,
50 calls: readonly GroupCall[],
51 id: string,
52 cwd: string,
53 { open, toggle }: Folds,
54 detailRuns: readonly (StyledRun[] | undefined)[] = [],
55 paintedOutput: readonly (OutputLine[] | undefined)[] = [],
56 fills: { reads?: string; output?: string } = {},
57 plain: Plain = NOT_PLAIN,
58 images: readonly (RenderElement | null)[] = [],
59): RenderElement {
60 const fillOf = (tool: string, lines: readonly OutputLine[]) =>
61 tool === "Read"
62 ? fills.reads
63 : tool === "Bash" || tool === "Grep"
64 ? outputFill(fills.output, false, lines)
65 : undefined;
66 const iconGap = plain.icons ? " " : " ";
67 const groupId = `group:${id}`;
68 const groupOpen = open.has(groupId);
69 const single = calls.length === 1;
70 const lit = (scope: string) =>
71 ({ scope, color: "text", dimColor: false, backgroundColor: "userMessageBackground", inverse: false }) as const;
72 const summaryLit = lit(groupId);
73
74 const summary = (
75 <Box flexDirection="row" marginTop={1} marginLeft={MARGIN} marginRight={MARGIN}>
76 <Button key={groupId} plain hover={summaryLit} onPress={() => toggle(groupId)}>
77 {segmentsOf(calls, plain.icons).flatMap((segment, i) => {
78 const { tone } = segment;
79
80 return [
81 <Text key={`icon-${i}`} color={tone} hover={summaryLit}>{`${segment.look.icon}${iconGap}`}</Text>,
82 <Text key={`label-${i}`} color={tone} bold hover={summaryLit}>{`${segment.look.label} `}</Text>,
83 <Text key={`count-${i}`} color={tone} hover={summaryLit}>{`${segment.count} `}</Text>,
84 ];
85 })}
86 </Button>
87 </Box>
88 );
89
90 if (!single && !groupOpen) {
91 return summary;
92 }
93
94 const rows = calls.map((call, i) => {
95 const callId = `call:${call.tool_use_id ?? `${id}:${i}`}`;
96 const look = lookFor(call.tool, plain.icons);
97 const input = inputOf(call.input);
98 const detail = look.detail(input);
99 const path = pathOf(look, input);
100 const tone = toneOf(call);
101 const lines = call.isRunning
102 ? null
103 : (paintedOutput[i] ?? parseOutput(call.tool, call.output, cwd, call.isErrored));
104 const callOpen = lines !== null && open.has(callId);
105 const rowLit = lit(callId);
106 const moreId = `more:${callId}`;
107 const press = () => {
108 if (lines !== null) {
109 toggle(callId);
110 }
111 };
112
113 return (
114 <Box key={callId} flexDirection="column" marginLeft={single ? 2 : 4} marginTop={single ? 1 : 0}>
115 <Box flexDirection="row" gap={2}>
116 <Button key={`row:${callId}`} plain hover={rowLit} onPress={press}>
117 <Text key="icon" color={tone} dimColor={call.isRunning} hover={rowLit}>{`${look.icon}${iconGap}`}</Text>
118 <Text key="label" color={tone} bold hover={rowLit}>
119 {look.label}
120 </Text>
121 {detail === undefined || path !== undefined ? null : (
122 <Text key="detail" dimColor wrap="truncate-end" hover={rowLit}>
123 {" "}
124 {lineChildren({ Text }, detail, detailRuns[i], rowLit)}
125 </Text>
126 )}
127 </Button>
128 {detail === undefined || path === undefined ? null : (
129 <Text dimColor wrap="truncate-end" hover={plain.links ? undefined : pathHover(callId)}>
130 {plain.links ? detail : <Link href={`file://${encodeURI(path)}`}>{shortPath(detail, cwd)}</Link>}
131 </Text>
132 )}
133 </Box>
134 {callOpen ? (
135 <Box marginLeft={2} flexDirection="column">
136 {outputCard(
137 { Box, Button, Text },
138 lines,
139 open.has(moreId),
140 () => toggle(moreId),
141 callId,
142 fillOf(call.tool, lines),
143 )}
144 </Box>
145 ) : null}
146 {images[i] ?? null}
147 </Box>
148 );
149 });
150
151 return (
152 <Box flexDirection="column">
153 {single ? null : summary}
154 {rows}
155 </Box>
156 );
157}
158hooks/heading.ts 20 lines1import type { Palette } from "../types";
2import type { Heading } from "./markdown";
3import { CELL_WIDTH, fill, svgDocument } from "./picture";
4import type { Font, Sketch } from "./picture";
5import { escapeXml, measure, wrap } from "./svg-text";
6
7// Type size by heading level; each fits one cell row at the default proportion.
8const SIZES = [46, 40, 34, 31];
9
10// A heading as a one-row picture, as wide as its text up to `availColumns`.
11export function headingSketch(heading: Heading, availColumns: number, palette: Palette, font: Font): Sketch {
12 const size = SIZES[heading.level - 1] ?? SIZES[SIZES.length - 1]!;
13 const width = Math.min(availColumns * CELL_WIDTH, Math.ceil(measure(heading.text, size) * 1.06) + CELL_WIDTH);
14 const columns = Math.max(1, Math.ceil(width / CELL_WIDTH));
15 const [text = ""] = wrap(heading.text, columns * CELL_WIDTH, size, 1);
16 const body = `<text x="0" y="${font.cellHeight / 2 + size * 0.35}" font-size="${size}" font-weight="bold" ${fill(heading.level <= 2 ? palette.accent : palette.text)}>${escapeXml(text)}</text>`;
17
18 return { svg: svgDocument(columns, 1, font.cellHeight, font.family, body), columns, rows: 1, alt: heading.text };
19}
20hooks/image.ts 255 lines1import { CELL_WIDTH, MAX_CELLS, svgDocument } from "./picture";
2import type { Font, Sketch } from "./picture";
3import { escapeXml } from "./svg-text";
4
5// An image a tool call returned or a prompt carried: an id unique in the session, its bytes as the model saw them, the
6// MIME type and a name to show. `size` is known up front when the tool reported it.
7export type Found = {
8 id: string;
9 base64: string;
10 mediaType: string;
11 name: string;
12 size?: Size;
13};
14
15export type Size = { width: number; height: number };
16
17// The marker a prompt's text holds in place of each image pasted into it.
18export const PASTED_MARKER = "[Image #";
19
20const isRecord = (value: unknown): value is Record<string, unknown> =>
21 typeof value === "object" && value !== null && !Array.isArray(value);
22
23// The image a Messages API block holds, or nothing for any other block.
24function ofBlock(block: unknown, id: string, name: string): Found[] {
25 if (!isRecord(block) || block.type !== "image" || !isRecord(block.source)) {
26 return [];
27 }
28
29 const { type, media_type: mediaType, data } = block.source;
30
31 return type === "base64" && typeof mediaType === "string" && typeof data === "string"
32 ? [{ id, base64: data, mediaType, name }]
33 : [];
34}
35
36const baseName = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
37
38function ofRead(id: string, path: string | undefined, output: unknown): Found[] {
39 if (!isRecord(output) || output.type !== "image" || !isRecord(output.file)) {
40 return [];
41 }
42
43 const { base64, type, dimensions } = output.file;
44
45 if (typeof base64 !== "string" || typeof type !== "string") {
46 return [];
47 }
48
49 const width = isRecord(dimensions) ? Number(dimensions.displayWidth) : NaN;
50 const height = isRecord(dimensions) ? Number(dimensions.displayHeight) : NaN;
51
52 return [
53 {
54 id: `${id}#0`,
55 base64,
56 mediaType: type,
57 name: path === undefined ? "image" : baseName(path),
58 size: width > 0 && height > 0 ? { width, height } : undefined,
59 },
60 ];
61}
62
63// The blocks of an MCP result: the list itself, or the `content` of a result object.
64const blocksOf = (output: unknown): unknown[] =>
65 Array.isArray(output) ? output : isRecord(output) && Array.isArray(output.content) ? output.content : [];
66
67function ofMcp(id: string, tool: string, output: unknown): Found[] {
68 const images = blocksOf(output).filter((block) => isRecord(block) && block.type === "image");
69 const name = tool.slice(tool.lastIndexOf("__") + 2);
70
71 return images.flatMap((block, at) => ofBlock(block, `${id}#${at}`, images.length > 1 ? `${name} ${at + 1}` : name));
72}
73
74// The images a finished tool call returned: a Read of an image file, or the image blocks of an MCP tool's result.
75export function imagesOf(call: { tool_use_id?: string; tool: string; output?: unknown }, path?: string): Found[] {
76 const { tool_use_id: id, tool, output } = call;
77
78 if (id === undefined) {
79 return [];
80 }
81
82 if (tool === "Read") {
83 return ofRead(id, path, output);
84 }
85
86 return tool.startsWith("mcp__") ? ofMcp(id, tool, output) : [];
87}
88
89// The images pasted into a prompt, from the conversation as the model reads it: those of the latest user message whose
90// text is the prompt's. A prompt row carries only its text, and the `[Image #n]` markers in it are numbered through the
91// session, so the text names its message. Null when no message holds the text, as before it is stored or after a
92// compaction dropped it.
93export function pastedImagesOf(
94 messages: ReadonlyArray<{ role: string; content: ReadonlyArray<unknown> }>,
95 text: string,
96 id: string,
97): Found[] | null {
98 const message = messages.findLast(
99 ({ role, content }) =>
100 role === "user" && content.some((block) => isRecord(block) && block.type === "text" && block.text === text),
101 );
102
103 if (message === undefined) {
104 return null;
105 }
106
107 const images = message.content.filter((block) => isRecord(block) && block.type === "image");
108
109 return images.flatMap((block, at) => ofBlock(block, `${id}#${at}`, `Image ${at + 1}`));
110}
111
112const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47];
113
114// An image file's size in pixels, read from its header; null for a format not known here or a header cut short.
115export function sizeOf(base64: string, mediaType: string): Size | null {
116 try {
117 const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
118 const at = (index: number) => bytes[index] ?? 0;
119 const big = (index: number) => (at(index) << 8) | at(index + 1);
120 const little = (index: number) => at(index) | (at(index + 1) << 8);
121
122 if (mediaType === "image/png" && PNG_SIGNATURE.every((byte, i) => at(i) === byte)) {
123 return { width: (big(16) << 16) | big(18), height: (big(20) << 16) | big(22) };
124 }
125
126 if (mediaType === "image/gif") {
127 return { width: little(6), height: little(8) };
128 }
129
130 if (mediaType === "image/jpeg") {
131 return jpegSize(bytes);
132 }
133
134 return mediaType === "image/webp" ? webpSize(bytes) : null;
135 } catch {
136 return null;
137 }
138}
139
140// The frame header of a JPEG: the first start-of-frame marker holds the height and then the width.
141function jpegSize(bytes: Uint8Array): Size | null {
142 const at = (index: number) => bytes[index] ?? 0;
143
144 for (let i = 2; i + 9 < bytes.length;) {
145 if (at(i) !== 0xff) {
146 i++;
147 continue;
148 }
149
150 const marker = at(i + 1);
151
152 if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
153 return { height: (at(i + 5) << 8) | at(i + 6), width: (at(i + 7) << 8) | at(i + 8) };
154 }
155
156 i += marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9) ? 2 : 2 + ((at(i + 2) << 8) | at(i + 3));
157 }
158
159 return null;
160}
161
162// The first chunk of a WebP file says its size in one of three layouts: lossy, lossless or extended.
163function webpSize(bytes: Uint8Array): Size | null {
164 const at = (index: number) => bytes[index] ?? 0;
165 const tag = String.fromCharCode(at(12), at(13), at(14), at(15));
166
167 if (tag === "VP8 ") {
168 return { width: (at(26) | (at(27) << 8)) & 0x3fff, height: (at(28) | (at(29) << 8)) & 0x3fff };
169 }
170
171 if (tag === "VP8L") {
172 return {
173 width: 1 + (((at(22) & 0x3f) << 8) | at(21)),
174 height: 1 + (((at(24) & 0x0f) << 10) | (at(23) << 2) | ((at(22) & 0xc0) >> 6)),
175 };
176 }
177
178 return tag === "VP8X"
179 ? {
180 width: 1 + (at(24) | (at(25) << 8) | (at(26) << 16)),
181 height: 1 + (at(27) | (at(28) << 8) | (at(29) << 16)),
182 }
183 : null;
184}
185
186// The cells a picture takes: its own size at 20 pixels a column, never wider than the room or taller than `maxRows`,
187// with its shape kept.
188export function boxFor(size: Size, available: number, maxRows: number, cellHeight: number) {
189 const tallest = Math.min(MAX_CELLS, maxRows);
190 let columns = Math.max(1, Math.min(available, Math.ceil(size.width / CELL_WIDTH)));
191 let rows = Math.ceil((columns * CELL_WIDTH * (size.height / size.width)) / cellHeight);
192
193 if (rows > tallest) {
194 rows = tallest;
195 columns = Math.max(1, Math.min(columns, Math.floor((rows * cellHeight * (size.width / size.height)) / CELL_WIDTH)));
196 }
197
198 return { columns, rows: Math.max(1, rows) };
199}
200
201const MEMORY_SIZES = 200;
202const sizes = new Map<string, Size | null>();
203
204// The size of an image: as the tool reported it, or read once from its header.
205export function sizeOfFound(found: Found): Size | null {
206 if (found.size !== undefined) {
207 return found.size;
208 }
209
210 const known = sizes.get(found.id);
211
212 if (known !== undefined) {
213 return known;
214 }
215
216 const size = sizeOf(found.base64, found.mediaType);
217
218 sizes.set(found.id, size);
219
220 if (sizes.size > MEMORY_SIZES) {
221 sizes.delete(sizes.keys().next().value as string);
222 }
223
224 return size;
225}
226
227// What the picture says where it cannot be drawn.
228export const altOf = ({ name }: Found, size: Size | null | undefined): string =>
229 size === null || size === undefined ? name : `${name} · ${size.width}×${size.height}`;
230
231// An SVG that holds the image inside the box of cells, shrunk to fit and centred, for `resvg` to decode and scale. Null
232// where the size is unknown.
233export function imageSketch(
234 found: Found,
235 available: number,
236 maxRows: number,
237 cellHeight: number,
238 font: Font,
239): Sketch | null {
240 const size = sizeOfFound(found);
241
242 if (size === null || size.width < 1 || size.height < 1) {
243 return null;
244 }
245
246 const { columns, rows } = boxFor(size, available, maxRows, cellHeight);
247 const width = columns * CELL_WIDTH;
248 const height = rows * cellHeight;
249 const image =
250 `<image x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet" ` +
251 `href="data:${escapeXml(found.mediaType)};base64,${found.base64}"/>`;
252
253 return { svg: svgDocument(columns, rows, cellHeight, font.family, image), columns, rows, alt: altOf(found, size) };
254}
255hooks/image-card.tsx 38 lines1import type { ElementTable, RenderElement } from "claude-code";
2
3import type { Picture } from "./picture";
4
5type Ui = Pick<ElementTable, "Box" | "Text"> & Partial<Pick<Extract<ElementTable, { Image: unknown }>, "Image">>;
6
7// An image as drawn: its picture, or null where it can only be named.
8export type Shown = { id: string; alt: string; picture: Picture | null };
9
10// The images under a row, one under another. An image with no picture, or any image where the terminal has no `Image`
11// element, is its name and size in dim text.
12export function imageColumn(
13 { Box, Text, Image }: Ui,
14 shown: readonly Shown[],
15 margin: number,
16 alignSelf: "flex-start" | "flex-end" = "flex-start",
17): RenderElement | null {
18 if (shown.length === 0) {
19 return null;
20 }
21
22 return (
23 <Box flexDirection="column" marginLeft={margin} marginRight={margin} alignSelf={alignSelf}>
24 {shown.map(({ id, alt, picture }) =>
25 picture === null || Image === undefined ? (
26 <Text key={id} dimColor>
27 {alt}
28 </Text>
29 ) : (
30 <Box key={id} flexShrink={0}>
31 <Image source={{ png: picture.png }} columns={picture.columns} rows={picture.rows} alt={alt} />
32 </Box>
33 ),
34 )}
35 </Box>
36 );
37}
38hooks/markdown.ts 163 lines1export type Align = "left" | "right" | "center";
2
3export type Table = { kind: "table"; header: string[]; align: Align[]; rows: string[][]; raw: string };
4
5export type Heading = { kind: "heading"; level: number; text: string; raw: string };
6
7export type Code = { kind: "code"; lang: string; code: string; raw: string };
8
9export type Segment = { kind: "text"; text: string } | Table | Heading | Code;
10
11const SEPARATOR = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
12const FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`]*)/;
13
14const closesFence = (line: string, marker: string): boolean => {
15 const text = line.trim();
16
17 return text.length >= marker.length && text === marker[0]!.repeat(text.length);
18};
19const HEADING = /^ {0,3}(#{1,4})\s+(.+?)(?:\s+#+)?\s*$/;
20
21// Inline marks read as noise in a picture, where they cannot be styled.
22const plain = (text: string): string =>
23 text
24 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
25 .replace(/\*\*|__|`/g, "")
26 .replace(/\\\|/g, "|")
27 .trim();
28
29const cellsOf = (line: string): string[] =>
30 line
31 .trim()
32 .replace(/^\|/, "")
33 .replace(/(?<!\\)\|$/, "")
34 .split(/(?<!\\)\|/)
35 .map(plain);
36
37const alignOf = (cell: string): Align => {
38 const spec = cell.trim();
39
40 if (spec.startsWith(":") && spec.endsWith(":")) {
41 return "center";
42 }
43
44 return spec.endsWith(":") ? "right" : "left";
45};
46
47const MEMORY_REPLIES = 64;
48const splits = new Map<string, Segment[]>();
49
50// The reply as runs of text with each table, heading and closed code fence taken out as its own segment. A fence still
51// open, as while the reply streams, stays text with everything after it. A reply is split once however often it is
52// drawn.
53export function splitReply(markdown: string): Segment[] {
54 const known = splits.get(markdown);
55
56 if (known !== undefined) {
57 return known;
58 }
59
60 const segments = split(markdown);
61
62 splits.set(markdown, segments);
63
64 if (splits.size > MEMORY_REPLIES) {
65 splits.delete(splits.keys().next().value as string);
66 }
67
68 return segments;
69}
70
71function split(markdown: string): Segment[] {
72 const lines = markdown.split("\n");
73 const segments: Segment[] = [];
74 let text: string[] = [];
75
76 const flush = () => {
77 if (text.join("\n").trim() !== "") {
78 segments.push({ kind: "text", text: text.join("\n") });
79 }
80
81 text = [];
82 };
83
84 for (let i = 0; i < lines.length; i++) {
85 const line = lines[i] ?? "";
86
87 const fence = FENCE.exec(line);
88
89 if (fence !== null) {
90 const marker = fence[2]!;
91 const close = lines.findIndex((other, at) => at > i && closesFence(other, marker));
92
93 if (close === -1) {
94 text.push(...lines.slice(i));
95 break;
96 }
97
98 const indent = fence[1]!.length;
99 const body = lines
100 .slice(i + 1, close)
101 .map((row) => row.slice(Math.min(indent, row.length - row.trimStart().length)));
102
103 flush();
104 segments.push({
105 kind: "code",
106 lang: fence[3]!.toLowerCase(),
107 code: body.join("\n"),
108 raw: lines.slice(i, close + 1).join("\n"),
109 });
110 i = close;
111 continue;
112 }
113
114 const heading = HEADING.exec(line);
115
116 // The last line of the text may still be streaming; a line is final once another follows it.
117 if (heading !== null && i < lines.length - 1) {
118 flush();
119 segments.push({ kind: "heading", level: heading[1]!.length, text: plain(heading[2]!), raw: line });
120 continue;
121 }
122
123 const separator = lines[i + 1] ?? "";
124 const header = line.includes("|") && SEPARATOR.test(separator) ? cellsOf(line) : [];
125 const isTable = header.length > 1 && cellsOf(separator).length === header.length;
126
127 let end = i + 2;
128
129 while (isTable && end < lines.length && (lines[end] ?? "").includes("|") && (lines[end] ?? "").trim() !== "") {
130 end++;
131 }
132
133 // A table is final once a line that is not a row follows it; until then it may still be growing.
134 if (!isTable || end >= lines.length) {
135 text.push(line);
136 continue;
137 }
138
139 const fit = (cells: string[]) => Array.from({ length: header.length }, (_, col) => cells[col] ?? "");
140
141 flush();
142 segments.push({
143 kind: "table",
144 header,
145 align: fit(
146 separator
147 .trim()
148 .replace(/^\||\|$/g, "")
149 .split("|"),
150 ).map(alignOf),
151 rows: lines.slice(i + 2, end).map((row) => fit(cellsOf(row))),
152 raw: lines.slice(i, end).join("\n"),
153 });
154 i = end - 1;
155 }
156
157 flush();
158
159 return segments;
160}
161
162export const hasDrawn = (segments: readonly Segment[]): boolean => segments.some((segment) => segment.kind !== "text");
163hooks/output.tsx 94 lines1import type { ElementTable, RenderElement } from "claude-code";
2
3import { foldButton, MAX_LINES, TAB } from "./fold";
4import { MARGIN } from "./reply";
5import { lineChildren } from "./syntax/runs";
6import type { StyledRun } from "./syntax/types";
7import { shortPath } from "./tool-row";
8
9type Ui = Pick<ElementTable, "Box" | "Text" | "Button">;
10
11export type OutputLine = { text: string; number?: number; tone?: "error"; runs?: StyledRun[] };
12
13const ESCAPE = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
14
15const linesOf = (text: string, tone?: "error"): OutputLine[] => {
16 const clean = text.replace(ESCAPE, "").replace(/\s+$/, "");
17
18 return clean === "" ? [] : clean.split("\n").map((line) => ({ text: line.replace(/\t/g, TAB), tone }));
19};
20
21const field = (value: unknown, key: string): unknown => (value as Record<string, unknown> | null | undefined)?.[key];
22
23const textOf = (value: unknown, key: string): string => {
24 const found = field(value, key);
25
26 return typeof found === "string" ? found : "";
27};
28
29// A finished tool's stored result as the lines its card shows, or null for a tool with no card or an empty result.
30export function parseOutput(tool: string, output: unknown, cwd: string, isErrored = false): OutputLine[] | null {
31 let lines: OutputLine[] = [];
32
33 if (isErrored) {
34 lines = typeof output === "string" ? linesOf(output, "error") : [];
35 } else if (tool === "Bash") {
36 lines = [...linesOf(textOf(output, "stdout")), ...linesOf(textOf(output, "stderr"), "error")];
37 } else if (tool === "Read" && field(output, "type") === "text") {
38 const file = field(output, "file");
39 const start = Number(field(file, "startLine")) || 1;
40
41 lines = linesOf(textOf(file, "content")).map((line, i) => ({ ...line, number: start + i }));
42 } else if (tool === "Grep" || tool === "Glob") {
43 const names = field(output, "filenames");
44 const content = textOf(output, "content");
45
46 lines =
47 content !== ""
48 ? linesOf(content)
49 : Array.isArray(names)
50 ? names.map((name) => ({ text: shortPath(String(name), cwd) }))
51 : [];
52 }
53
54 return lines.length === 0 ? null : lines;
55}
56
57// The result as a rounded card of lines, the same shape as the diff card, with a numbered column where lines carry
58// numbers and the error colour on what a command wrote to stderr. A background fills the card.
59export function outputCard(
60 ui: Ui,
61 lines: OutputLine[],
62 expanded: boolean,
63 toggle: () => void,
64 id: string,
65 background?: string,
66): RenderElement {
67 const { Box, Text } = ui;
68 const shown = expanded ? lines : lines.slice(0, MAX_LINES);
69 const digits = Math.max(0, ...shown.map((line) => String(line.number ?? "").length));
70
71 return (
72 <Box
73 flexDirection="column"
74 marginLeft={MARGIN}
75 marginRight={MARGIN}
76 borderStyle="round"
77 borderColor="subtle"
78 backgroundColor={background}
79 >
80 {shown.map((line, i) => (
81 <Box key={`line-${i}`} flexDirection="row">
82 {digits > 0 ? <Text dimColor>{` ${String(line.number ?? "").padStart(digits)} `}</Text> : null}
83 <Box flexGrow={1} flexShrink={1} paddingLeft={1}>
84 <Text color={line.tone} wrap="wrap">
85 {lineChildren(ui, line.text, line.runs)}
86 </Text>
87 </Box>
88 </Box>
89 ))}
90 {foldButton(ui, lines.length, expanded, toggle, id)}
91 </Box>
92 );
93}
94