SLOPSHOPPER

Veneer

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

newpanespinnerrowscommandtoast
★ 1v0.1.1MITupdated 2026-10-15MitchMedeiros/veneer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · veneer
│ ┃ Veneer ✕ │ ┃ tab:settings tab:syntax ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▖ │ ┃ ▐ fix the failing auth test and add an audit log call ▌ │ ┃ Themes ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▘ │ ┃ ● Default │ ┃ + Browse popular themes... ⏺ Read(src/auth.ts) │ ┃ + New custom theme... ⎿ Read 6 lines │ ┃ │ ┃ typescript Edit file:///work/app/src/auth.ts │ ┃ ╭─────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ // Sum the lengths of the words │ ⎿ 3 pass, 1 fail │ ┃ │ export function total(words: string[]): │ │ ┃ │ number { │ Done. I made refresh reject expired claims and added an audit call │ ┃ │ const empty = words.filter((word) => │ │ ┃ │ word !== ""); │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ return empty.reduce((sum, word) => │ │ ┃ │ sum + word.length, 0); │ › /veneer │ ┃ │ } │ │ ┃ ╰─────────────────────────────────────────╯ │ ┃ java │ ┃ ╭─────────────────────────────────────────╮ │ ┃ │ // Greet each name │ │ ┃ │ static void greet(List<String> names) { │ │ ┃ │ names.forEach(name -> │ │ ┃ │ System.out.println("Hello, " + name)); │ │ ┃ │ } │ ✻ Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Veneer
tab:settings tab:syntax Themes ● Default + Browse popular themes... + New custom theme... typescript ╭─────────────────────────────────────────────────────────╮ │ // Sum the lengths of the words │ │ export function total(words: string[]): number { │ │ const empty = words.filter((word) => word !== ""); │ │ return empty.reduce((sum, word) => sum + word.length, │ │ 0); │ │ } │ ╰─────────────────────────────────────────────────────────╯ java ╭─────────────────────────────────────────────────────────╮ │ // Greet each name │ │ static void greet(List<String> names) { │ │ names.forEach(name -> System.out.println("Hello, " │ │ + name)); │ │ } │ ╰─────────────────────────────────────────────────────────╯ python ╭─────────────────────────────────────────────────────────╮ │ # Count the words │ │ def count(text: str) -> dict[str, int]: │ │ words = text.lower().split() │ │ return {word: words.count(word) for word in │ │ set(words)} │ ╰─────────────────────────────────────────────────────────╯ Enter to select · Esc to cancel
Spinner
✻ Thinking…
Your message
▗▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▖ ▐ fix the failing auth test and add an audit log call ▌ ▝▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▘
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
Edit file:///work/app/src/auth.ts
README

Veneer

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.

Table of Contents

Installing

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

Terminal Setup

Veneer works in any terminal. The sections below cover optional setup for specific terminals and features.

Image Support

To see diagrams and tables as images, and image previews in messages, you need:

This is a limitation of how Claude Code displays images.

Nerd Font Icons

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.

Rio

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.

tmux

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.

Syntax Highlighting

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:

  • the page URL or publisher.name for a theme on Open VSX
  • a path to a local theme file

If a theme on Open VSX has multiple variants (e.g. light, dark), you will be asked to choose one.

General Settings

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 NameSettingDefaultUse
Message alignmentmessageAlignleftleft or right
Message stylingstyleUserMessagetrueDraw your messages as a bubble. Off leaves it to a mod.
Iconsiconsautoauto, nerd or plain. Nerd Font or Unicode icons.
File linkslinksautoauto, link or text. How file paths are drawn.
Text-based graphicsnativeGraphicsfalseDraw tables, headings and Mermaid diagrams as text.
Diagram hintdiagramHinttrueOff stops Claude from responding with diagrams.
Spinner stylingstyleSpinnertrueTurn off to leave spinner styling to another mod.
Table fontfontFamilyblankFont for tables and headings. Blank uses defaults.
Cell proportioncellRatio2.9Terminal cell height over width. Fixes letterboxing.
Code blocksfenceBackgroundtrueFill code blocks with the syntax theme's background.
File readsreadBackgroundtrueThe same for the card of a file read.
Command and search outputoutputBackgroundtrueThe same for highlighted Bash and Grep output.
DiffsdiffBackgroundtrueThe same for diffs. Added and removed lines keep theirs.

Settings are stored under pluginConfigs in the global ~/.claude/settings.json.

Diagrams and charts

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:

KindOpens with
Flowchartgraph or flowchart, without subgraphs
StatestateDiagram or stateDiagram-v2
SequencesequenceDiagram
ClassclassDiagram
ERerDiagram
Piepie
XY chartxychart or xychart-beta, bars and lines, vertical or horizontal
Radarradar-beta
Sankeysankey or sankey-beta
Treemaptreemap-beta
Quadrant chartquadrantChart
Ganttgantt, with dependencies, milestones, dateFormat and axisFormat
Timelinetimeline
User journeyjourney
Kanbankanban, with ticket, assignee and priority metadata
Mind mapmindmap
Git graphgitGraph, with branches, merges, tags and cherry-picks
Packetpacket or packet-beta
Blockblock-beta, with spans, spaces, nested blocks and arrows
Architecturearchitecture-beta, with groups, services, junctions and ports
C4C4Context, C4Container, C4Component, C4Dynamic and C4Deployment

Supported Languages and File Types

Syntax highlighting is supported for 106 languages and file types:

  • Web: TypeScript, TSX, JavaScript, JSX, JSON, JSONC, JSON5, JSON Lines, HTML, CSS, SCSS, Sass, Less, Stylus, Vue, Pug, Handlebars, Jinja, Twig, Razor, GraphQL
  • Scripting and shells: Shell, Shell Session, Fish, PowerShell, Batch, AWK, Ruby, PHP, Perl, Lua, R, Julia, MATLAB, Tcl, Vim script
  • Systems and general purpose: Python, C, C++, Objective-C, Objective-C++, C#, F#, Visual Basic, Java, Kotlin, Scala, Groovy, Swift, Dart, Go, Rust, Zig, D, Nim, Crystal, Pascal, Fortran, COBOL, Solidity, GDScript
  • Functional and Lisp: Haskell, OCaml, Elm, PureScript, Elixir, Erlang, Clojure, Racket, Scheme, Common Lisp, Emacs Lisp
  • Data and config: YAML, TOML, INI, dotenv, XML, Protocol Buffers, Prisma, Terraform, HCL, Nix, Bicep, Dockerfile, Makefile, CMake, Nginx, Apache, systemd, SSH config, HTTP, Git commit messages
  • Documents: Markdown, MDX, LaTeX, BibTeX, reStructuredText, AsciiDoc, diff
  • Shaders and hardware: GLSL, HLSL, WGSL, Verilog, SystemVerilog, VHDL
  • SQL

Contributing

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:

CommandDoes
npm run typecheckType-check with tsc
npm run validateRun claude plugin validate on the repo
npm testRun claude plugin test on the repo
npm run build:grammarsRebuild grammars/ from tools/languages.json
npm run conformanceCompare the tokenizer with Shiki on tools/samples/
npm run check:themesFetch the popular themes from Open VSX (network, not in CI; -- --all for every theme)
Source 73 files
hooks/register.tsx 1114 lines
1import { 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};
1114
hooks/config.ts 78 lines
1import 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});
78
hooks/gate.ts 70 lines
1import 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));
70
hooks/badge.tsx 62 lines
1import 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}
62
hooks/diff.tsx 135 lines
1import 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}
135
hooks/fold.tsx 32 lines
1import 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}
32
hooks/group.tsx 158 lines
1import 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}
158
hooks/heading.ts 20 lines
1import 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}
20
hooks/image.ts 255 lines
1import { 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}
255
hooks/image-card.tsx 38 lines
1import 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}
38
hooks/markdown.ts 163 lines
1export 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");
163
hooks/output.tsx 94 lines
1import 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