A small Catppuccin skin for Claude Code tool rows and turn footers

A small Claude Code mod that colours tool headers, Bash result borders, and turn footers with Catppuccin Mocha or Latte. It draws Markdown tables as boxed grids and a final Over to you blockquote as a card. It leaves prompts and ordinary assistant prose to Claude Code. Tool blocks and the turn footer get a little extra space.
In verbose mode and the ctrl+o transcript, tool rows inside expanded groups show the skin's header without their inline output.
Try it from this repository with claude --plugin-dir src/plugins/skin. The plugin chooses Latte for a light Claude theme or a light COLORFGBG terminal background; otherwise it uses Mocha.
Inspired by hellosverre/claude-skins, which is MIT licensed. This mod is a separate implementation.
hooks/register.tsx 236 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import { paletteFor, palettes } from "./palette";
5import { replySegments, tableLines } from "./reply";
6
7/** The number of tool calls in the current main turn, failed ones included, for the footer. */
8const toolCount = atom({ plugin: "skin", key: "toolCount" } as const, 0);
9
10/**
11 * The tool count of each finished main turn, so older footers keep their own number. Footers carry no turn ID,
12 * so turns are keyed by their length in milliseconds; two turns of exactly the same length share an entry.
13 */
14const footerCounts = atom({ plugin: "skin", key: "footerCounts" } as const, {} as Record<string, number>);
15
16/** The palette picked when the session started, or null until it has been picked. */
17const paletteName = atom({ plugin: "skin", key: "paletteName" } as const, null as "mocha" | "latte" | null);
18
19/** Register the skin's reply, tool, and footer drawing. */
20export const register: Register = on => {
21 on("session.start", async ($, event, next) => {
22 const selected = await resolvePaletteName($);
23
24 await update($, paletteName, () => selected);
25
26 return next(event);
27 });
28
29 on("turn.start", async ($, event, next) => {
30 await update($, toolCount, () => 0);
31
32 return next(event);
33 });
34
35 on("tool.call", async ($, event, next) => {
36 if (event.agentId === undefined) {
37 await update($, toolCount, count => count + 1);
38 }
39
40 return next(event);
41 });
42
43 on("turn.complete", async ($, event, next) => {
44 if (event.agentId === undefined) {
45 const count = await read($, toolCount);
46
47 await update($, footerCounts, counts => ({ ...counts, [String(event.durationMs)]: count }));
48 }
49
50 return next(event);
51 });
52
53 // Draws reply tables as boxed grids and a closing Over to you quote as a card, with the reply bullet and indent Claude Code would draw.
54 // Summaries, replies without either, and tables too narrow to box keep Claude Code's drawing.
55 on("ui.render", { component: "AssistantMessage" }, async ($, event, next) => {
56 // A summary row carries a mark from Claude Code that a custom drawing would lose.
57 if (event.props.isSummary) {
58 return next(event);
59 }
60
61 // Two cells go to the bullet column. A surface that has not measured its width gets 100 columns.
62 const segments = replySegments(event.props.text, (event.viewport?.columns ?? 100) - 2);
63
64 if (segments === null) {
65 return next(event);
66 }
67
68 const { Box, Markdown, Text } = $.ui.resolve(event);
69 const palette = await activePalette($);
70 const rows = segments.map((segment, index) => {
71 let content;
72
73 if (segment.kind === "prose") {
74 content = <Markdown text={segment.text} />;
75 } else if (segment.kind === "card") {
76 content = (
77 <Box key={`card-${index}`} position="relative" borderStyle="round" borderColor={palette.accent} paddingX={1}>
78 <Box key={`card-title-${index}`} position="absolute" top={-1} left={2}><Text color={palette.accent} bold> Over to you </Text></Box>
79 <Markdown text={segment.text} />
80 </Box>
81 );
82 } else {
83 content = (
84 <Box flexDirection="column">
85 {tableLines(segment.rows, segment.widths).map((line, lineIndex) => {
86 if (line.kind === "border") {
87 return <Text key={lineIndex} color={palette.border}>{line.text}</Text>;
88 }
89
90 const cells = line.cells.map((cell, cellIndex) => {
91 const spans = cell.map((span, spanIndex) => (
92 <Text key={spanIndex} color={line.header || span.code ? palette.accent : undefined} bold={line.header || span.bold}>
93 {span.text}
94 </Text>
95 ));
96 const divider = cellIndex < line.cells.length - 1 ? " │ " : " │";
97
98 return <Text key={cellIndex}>{spans}<Text color={palette.border}>{divider}</Text></Text>;
99 });
100
101 return (
102 <Text key={lineIndex}>
103 <Text color={palette.border}>│ </Text>
104 {cells}
105 </Text>
106 );
107 })}
108 </Box>
109 );
110 }
111
112 return <Box key={`segment-${index}`} flexDirection="column" marginTop={index > 0 ? 1 : 0}>{content}</Box>;
113 });
114
115 return (
116 <Box flexDirection="row">
117 <Box width={2} flexShrink={0}>
118 {event.props.isFirstOfReply && <Text>⏺ </Text>}
119 </Box>
120 <Box flexDirection="column" flexGrow={1}>{rows}</Box>
121 </Box>
122 );
123 });
124
125 // Replaces Claude Code's tool header with a `●─ Tool summary` line whose dot shows the call's status.
126 on("ui.render", { component: "ToolUse" }, async ($, event) => {
127 const { Box, Text } = $.ui.resolve(event);
128 const palette = await activePalette($);
129 const { input, isErrored, isInterrupted, isRunning, tool } = event.props;
130 const fields = input !== null && typeof input === "object" ? input as Record<string, unknown> : {};
131 const summary = toolSummary(fields);
132 const status = isRunning ? palette.running : isInterrupted ? palette.muted : isErrored ? palette.failed : palette.done;
133
134 return (
135 <Box marginTop={1}>
136 <Text wrap="truncate-end">
137 <Text color={status}>●─ </Text>
138 <Text color={palette.accent} bold>{tool}</Text>
139 {summary && <Text color={palette.muted}>{` ${summary}`}</Text>}
140 {isInterrupted && <Text color={palette.muted}> interrupted</Text>}
141 </Text>
142 </Box>
143 );
144 });
145
146 // Keeps Claude Code's own Bash output and draws a border around it. Other tools' results are left alone.
147 on("ui.render", { component: "ToolResult" }, async ($, event, next) => {
148 if (event.props.tool !== "Bash") {
149 return next(event);
150 }
151
152 const { Box } = $.ui.resolve(event);
153 const palette = await activePalette($);
154
155 return <Box borderStyle="round" borderColor={palette.border} paddingX={1}>{await next(event)}</Box>;
156 });
157
158 // Replaces the closing line of a turn with `◆ Done in <duration> · <N> tools`.
159 on("ui.render", { component: "TurnDuration" }, async ($, event) => {
160 const { Box, Text } = $.ui.resolve(event);
161 const palette = await activePalette($);
162 const counts = await read($, footerCounts);
163 const count = counts[String(event.props.durationMs)] ?? await read($, toolCount);
164 const tools = count === 1 ? "tool" : "tools";
165
166 return (
167 <Box marginTop={1}>
168 <Text color={palette.accent}>◆ </Text>
169 <Text color={palette.muted}>{`Done in ${formatDuration(event.props.durationMs)} · ${count} ${tools}`}</Text>
170 </Box>
171 );
172 });
173};
174
175/**
176 * Find what a tool call is acting on, for its header: the first non-empty command, path, pattern, query, address
177 * or description, on one line.
178 *
179 * @param {Record<string, unknown>} fields
180 * The tool call's input. Returns an empty string when none of the fields is a non-empty string.
181 */
182function toolSummary(fields: Record<string, unknown>): string {
183 for (const key of ["command", "file_path", "notebook_path", "path", "pattern", "query", "url", "description"]) {
184 const value = fields[key];
185
186 if (typeof value === "string" && value.trim()) {
187 return value.replace(/\s+/g, " ").trim();
188 }
189 }
190
191 return "";
192}
193
194/**
195 * Format a turn's length for the footer, such as `41s` or `2m 5s`.
196 *
197 * @param {number} durationMs
198 * The turn's length in milliseconds.
199 */
200function formatDuration(durationMs: number): string {
201 const seconds = Math.round(durationMs / 1000);
202 const minutes = Math.floor(seconds / 60);
203 const remainder = seconds % 60;
204
205 return minutes > 0 ? `${minutes}m ${remainder}s` : `${seconds}s`;
206}
207
208/**
209 * Get the session's palette, picking it now if the session started before the skin loaded.
210 *
211 * @param {EngineInterface} $
212 * The engine for the current hook.
213 */
214async function activePalette($: EngineInterface) {
215 const selected = await read($, paletteName);
216
217 if (selected !== null) {
218 return palettes[selected];
219 }
220
221 return palettes[await resolvePaletteName($)];
222}
223
224/**
225 * Pick Latte or Mocha from the user's Claude Code theme setting and the terminal's background.
226 *
227 * @param {EngineInterface} $
228 * The engine for the current hook.
229 */
230async function resolvePaletteName($: EngineInterface): Promise<"mocha" | "latte"> {
231 const theme = (await $.config.list()).find(row => row.key === "theme")?.value;
232 const colourHint = await $.env.get("COLORFGBG");
233
234 return paletteFor(theme, colourHint) === palettes.latte ? "latte" : "mocha";
235}
236hooks/palette.ts 59 lines1/** The colours the skin uses for the rows it draws itself. */
2type Palette = {
3 /** The tool name and the footer's diamond. */
4 accent: string;
5 /** The rounded border around Bash output. */
6 border: string;
7 /** The status dot of a tool call that finished. */
8 done: string;
9 /** The status dot of a tool call that failed. */
10 failed: string;
11 /** The tool header's summary and the footer text. */
12 muted: string;
13 /** The status dot of a tool call that is still running. */
14 running: string;
15};
16
17/** Catppuccin Mocha for dark backgrounds and Latte for light ones. */
18export const palettes = {
19 mocha: {
20 accent: "#cba6f7",
21 border: "#585b70",
22 done: "#a6e3a1",
23 failed: "#f38ba8",
24 muted: "#a6adc8",
25 running: "#f9e2af",
26 },
27 latte: {
28 accent: "#8839ef",
29 border: "#9ca0b0",
30 done: "#40a02b",
31 failed: "#d20f39",
32 muted: "#6c6f85",
33 running: "#df8e1d",
34 },
35} as const satisfies Record<string, Palette>;
36
37/**
38 * Pick Latte for a light background and Mocha otherwise.
39 *
40 * @param {unknown} theme
41 * The user's Claude Code theme setting, such as `light` or `dark-ansi`. It wins when it names light or dark.
42 * @param {string|undefined} colourHint
43 * The terminal's `COLORFGBG` value, `<foreground>;<background>`, used when the theme doesn't say.
44 * A background of 7 or 15 (white) means a light terminal.
45 */
46export function paletteFor(theme: unknown, colourHint: string | undefined) {
47 if (typeof theme === "string" && /light|latte/i.test(theme)) {
48 return palettes.latte;
49 }
50
51 if (typeof theme === "string" && /dark|mocha/i.test(theme)) {
52 return palettes.mocha;
53 }
54
55 const background = colourHint?.split(";").at(-1);
56
57 return background === "7" || background === "15" ? palettes.latte : palettes.mocha;
58}
59hooks/reply.ts 336 lines1/** The pieces a reply splits into: prose for Claude Code's Markdown, and tables and the closing Over to you quote for the skin to draw. */
2export type ReplySegment =
3 | { kind: "prose"; text: string }
4 | { kind: "table"; rows: string[][]; widths: number[] }
5 | { kind: "card"; text: string };
6
7/** Visible text and its inline Markdown style in a table cell. */
8export type CellSpan = { text: string; bold: boolean; code: boolean };
9
10/** One drawn row of a table: a border line, or one line of wrapped cell text. */
11export type TableLine =
12 | { kind: "border"; text: string }
13 | { kind: "cells"; cells: CellSpan[][]; header: boolean };
14
15/** The bold "Over to you:" or "Over to you" label that opens the closing quote. */
16const OVER_TO_YOU_LABEL = /^\*\*Over to you(?::\*\*|\*\*)/;
17/**
18 * Characters most terminals draw two cells wide (East Asian scripts and emoji), which would push table borders out of line.
19 * The emoji range also matches some narrow symbols such as © and ™, so tables holding those keep Claude Code's drawing too.
20 */
21const WIDE_CHARACTER = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE10-\uFE6F\uFF01-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]|\p{Extended_Pictographic}/u;
22
23/**
24 * Split a reply into prose, tables and a final Over to you quote, so the skin
25 * can draw the tables and the quote itself.
26 *
27 * The card segment holds the quote's contents without the quote markers or
28 * the label. Returns null when the reply has neither, or when any table cannot
29 * be boxed, so that Claude Code draws the whole reply as usual. Tables inside
30 * code fences are left as prose.
31 *
32 * @param {string} text
33 * The reply's Markdown.
34 * @param {number} columns
35 * The width the reply is drawn into, in character cells.
36 */
37export function replySegments(text: string, columns: number): ReplySegment[] | null {
38 const lines = text.split("\n");
39 let cardStart = lines.length;
40 let quoteSeen = false;
41
42 for (let index = lines.length - 1; index >= 0; index--) {
43 if (/^\s*$/.test(lines[index])) {
44 if (quoteSeen) {
45 break;
46 }
47
48 continue;
49 }
50
51 if (!/^>\s?/.test(lines[index])) {
52 break;
53 }
54
55 quoteSeen = true;
56 cardStart = index;
57 }
58
59 const quoted = lines.slice(cardStart).map(line => line.replace(/^>\s?/, ""));
60 const card = cardStart < lines.length && OVER_TO_YOU_LABEL.test(quoted[0])
61 ? [quoted[0].replace(OVER_TO_YOU_LABEL, ""), ...quoted.slice(1)].join("\n").trim()
62 : null;
63 const body = card === null ? lines : lines.slice(0, cardStart);
64 const segments: ReplySegment[] = [];
65 let proseStart = 0;
66 let fence: { character: string; length: number } | null = null;
67 /** Add a run of reply lines as prose, without blank lines at either end, so pieces stay one blank line apart. */
68 const addProse = (start: number, end: number) => {
69 const prose = body.slice(start, end);
70
71 while (prose.length > 0 && /^\s*$/.test(prose[0])) {
72 prose.shift();
73 }
74
75 while (prose.length > 0 && /^\s*$/.test(prose.at(-1) ?? "")) {
76 prose.pop();
77 }
78
79 if (prose.length > 0) {
80 segments.push({ kind: "prose", text: prose.join("\n") });
81 }
82 };
83
84 for (let index = 0; index < body.length - 1; index++) {
85 const marker = body[index].match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
86
87 if (marker) {
88 if (fence === null) {
89 fence = { character: marker[1][0], length: marker[1].length };
90 } else if (marker[1][0] === fence.character && marker[1].length >= fence.length && /^\s*$/.test(marker[2])) {
91 fence = null;
92 }
93
94 continue;
95 }
96
97 if (fence !== null) {
98 continue;
99 }
100
101 const header = cells(body[index]);
102 const divider = cells(body[index + 1]);
103
104 if (!header || !divider || header.length !== divider.length ||
105 !divider.every(cell => /^:?-{3,}:?$/.test(cell))) {
106 continue;
107 }
108
109 const rows = [header];
110 let end = index + 2;
111
112 while (end < body.length) {
113 const row = cells(body[end]);
114
115 if (!row) {
116 break;
117 }
118
119 rows.push(Array.from({ length: header.length }, (_, column) => row[column] ?? ""));
120 end++;
121 }
122
123 const widths = tableWidths(rows, columns);
124
125 if (widths === null) {
126 return null;
127 }
128
129 addProse(proseStart, index);
130 segments.push({ kind: "table", rows, widths });
131 proseStart = end;
132 index = end - 1;
133 }
134
135 addProse(proseStart, body.length);
136
137 if (card !== null) {
138 segments.push({ kind: "card", text: card });
139 }
140
141 return segments.some(segment => segment.kind !== "prose") ? segments : null;
142}
143
144/**
145 * Lay out one table as rounded border lines and rows of padded cells. A cell
146 * that is wider than its column wraps onto extra lines.
147 *
148 * @param {string[][]} rows
149 * The table's cells, header row first, still holding their inline Markdown.
150 * @param {number[]} widths
151 * The width of each column's text, from `replySegments`.
152 */
153export function tableLines(rows: string[][], widths: number[]): TableLine[] {
154 const border = (left: string, middle: string, right: string) =>
155 left + widths.map(width => "─".repeat(width + 2)).join(middle) + right;
156 const lines: TableLine[] = [{ kind: "border", text: border("╭", "┬", "╮") }];
157
158 for (const [rowIndex, row] of rows.entries()) {
159 const wrapped = row.map((cell, index) => wrap(inlineSpans(cell), widths[index]));
160 const height = Math.max(...wrapped.map(cell => cell.length));
161
162 for (let line = 0; line < height; line++) {
163 lines.push({
164 kind: "cells",
165 cells: wrapped.map((cell, index) => {
166 const spans = cell[line] ?? [];
167 const padding = widths[index] - spans.reduce((length, span) => length + span.text.length, 0);
168
169 return [...spans, { text: " ".repeat(padding), bold: false, code: false }];
170 }),
171 header: rowIndex === 0,
172 });
173 }
174
175 if (rowIndex === 0 && rows.length > 1) {
176 lines.push({ kind: "border", text: border("├", "┼", "┤") });
177 }
178 }
179
180 lines.push({ kind: "border", text: border("╰", "┴", "╯") });
181
182 return lines;
183}
184
185/**
186 * Read one Markdown table row into its trimmed cells, or return null when the
187 * line is not a table row. A table inside a quote is left for Claude Code to
188 * draw as part of the quote.
189 *
190 * @param {string} line
191 * One line of the reply.
192 */
193function cells(line: string): string[] | null {
194 if (!line.includes("|") || /^\s*>/.test(line)) {
195 return null;
196 }
197
198 const trimmed = line.trim();
199 const parts = trimmed.replace(/^\|/, "").replace(/\|$/, "").split(/(?<!\\)\|/);
200
201 return parts.length > 1 || trimmed.startsWith("|")
202 ? parts.map(part => part.trim().replace(/\\\|/g, "|"))
203 : null;
204}
205
206/**
207 * Choose each column's text width so the boxed table fits the available width.
208 * Columns start at their longest cell, and the widest one shrinks first, but
209 * never below its longest word or 6 cells, whichever is shorter. Returns null
210 * when the table cannot fit at those widths or holds wide characters.
211 *
212 * @param {string[][]} rows
213 * The table's cells, header row first.
214 * @param {number} columns
215 * The width the reply is drawn into, in character cells.
216 */
217function tableWidths(rows: string[][], columns: number): number[] | null {
218 if (rows.some(row => row.some(cell => WIDE_CHARACTER.test(cell)))) {
219 return null;
220 }
221
222 const count = rows[0].length;
223 const available = columns - (3 * count + 1);
224
225 const widths = Array.from({ length: count }, (_, index) =>
226 Math.max(1, ...rows.map(row => visibleText(inlineSpans(row[index])).length)));
227 const floors = Array.from({ length: count }, (_, index) =>
228 Math.min(6, Math.max(1, ...rows.flatMap(row => visibleText(inlineSpans(row[index])).split(/\s+/).map(word => word.length)))));
229
230 while (widths.reduce((sum, width) => sum + width, 0) > available) {
231 let widest = -1;
232
233 for (let index = 0; index < widths.length; index++) {
234 if (widths[index] > floors[index] && (widest === -1 || widths[index] > widths[widest])) {
235 widest = index;
236 }
237 }
238
239 if (widest === -1) {
240 return null;
241 }
242
243 widths[widest]--;
244 }
245
246 return widths;
247}
248
249/**
250 * Split a cell into plain, bold and code spans, removing the `**` and
251 * backtick markers. Other inline Markdown is shown as written.
252 *
253 * @param {string} text
254 * The cell's Markdown.
255 */
256function inlineSpans(text: string): CellSpan[] {
257 const spans: CellSpan[] = [];
258 const pattern = /(\*\*([^*]+)\*\*|`([^`]+)`)/g;
259 let start = 0;
260
261 for (const match of text.matchAll(pattern)) {
262 if (match.index > start) {
263 spans.push({ text: text.slice(start, match.index), bold: false, code: false });
264 }
265
266 spans.push({ text: match[2] ?? match[3], bold: match[2] !== undefined, code: match[3] !== undefined });
267 start = match.index + match[0].length;
268 }
269
270 if (start < text.length) {
271 spans.push({ text: text.slice(start), bold: false, code: false });
272 }
273
274 return spans;
275}
276
277/**
278 * Join a cell's spans into the text that appears on screen, for measuring.
279 *
280 * @param {CellSpan[]} spans
281 * The cell's spans.
282 */
283function visibleText(spans: CellSpan[]): string {
284 return spans.map(span => span.text).join("");
285}
286
287/**
288 * Wrap a cell onto lines no wider than its column, breaking at spaces where it
289 * can and keeping each span's style. An empty cell gives one empty line.
290 *
291 * @param {CellSpan[]} spans
292 * The cell's spans.
293 * @param {number} width
294 * The column's text width.
295 */
296function wrap(spans: CellSpan[], width: number): CellSpan[][] {
297 const text = visibleText(spans);
298 const lines: CellSpan[][] = [];
299 let start = 0;
300
301 while (start < text.length) {
302 let end = Math.min(start + width, text.length);
303
304 if (end < text.length && !/\s/.test(text[end]) && !/\s/.test(text[end - 1])) {
305 const breakAt = text.lastIndexOf(" ", end - 1);
306
307 if (breakAt > start) {
308 end = breakAt;
309 }
310 }
311
312 const parts: CellSpan[] = [];
313 let offset = 0;
314
315 for (const span of spans) {
316 const from = Math.max(start - offset, 0);
317 const to = Math.min(end - offset, span.text.length);
318
319 if (to > from) {
320 parts.push({ ...span, text: span.text.slice(from, to) });
321 }
322
323 offset += span.text.length;
324 }
325
326 lines.push(parts);
327 start = end;
328
329 while (text[start] === " ") {
330 start++;
331 }
332 }
333
334 return lines.length > 0 ? lines : [[]];
335}
336types/index.d.ts 14 lines1/** Session state owned by the skin plugin. */
2declare module "claude-code" {
3 interface PluginState {
4 skin: {
5 /** Tool calls in the current main turn. */
6 toolCount: number;
7 /** Each finished main turn's tool count, keyed by the turn's length in milliseconds. */
8 footerCounts: Record<string, number>;
9 /** The palette picked for this session, or null before it has been picked. */
10 paletteName: "mocha" | "latte" | null;
11 };
12 }
13}
14