SLOPSHOPPER

skin

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

newspinnerrowsguard
v0.1.0no licenseupdated 2026-10-12lewishowles/agents/src/plugins/skin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · skin
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ●─ Edit /work/app/src/auth.ts ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ◆ Done in 42s · 9 tools ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Turn line
◆ Done in 42s · 9 tools
Tool row
●─ Edit /work/app/src/auth.ts
README

Catppuccin skin

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.

Source 4 files
hooks/register.tsx 236 lines
1import { 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}
236
hooks/palette.ts 59 lines
1/** 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}
59
hooks/reply.ts 336 lines
1/** 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}
336
types/index.d.ts 14 lines
1/** 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