Coral, ink and cream reskin of Claude Code with risk-coded tool rows. Toggle with /skin.

Changes supported Claude Code message and tool rows to a coral, ink and cream style. Colored chips help you spot reads, edits and commands.
See the complete guide and creation prompt.
Test from the kit root:
claude plugin validate plugins/coral-skin
claude plugin test plugins/coral-skinhooks/coral-skin.mjs 363 lines1// Coral Skin: coral, ink and cream reskin with risk-coded tool rows.
2// Visual only. Uses no filesystem, network, model, or permission APIs.
3// Toggle with /skin, /skin on, /skin off, /skin risk.
4
5import { update } from "claude-code";
6
7const C = {
8 coral: "#e8603c",
9 coralDim: "#a8472e",
10 ink: "#141413",
11 card: "#1f1c19",
12 cream: "#f6efe3",
13 paper: "#fbf6ee",
14 muted: "#b8ad9c",
15 sage: "#9cc5a1",
16 sageBg: "#1e2a21",
17 amber: "#f0a33a",
18 red: "#d93b3b",
19};
20
21const prefsRef = { plugin: "coral-skin", key: "prefs" };
22const turnRef = { plugin: "coral-skin", key: "turn" };
23const STORE_KEY = "prefs";
24const DEFAULT_PREFS = { enabled: true, risk: true };
25const EMPTY_TURN = { active: false, startedAt: 0, now: 0, frame: 0, reads: 0, edits: 0, bash: 0, danger: 0, other: 0 };
26
27const READ_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "WebFetch", "WebSearch", "NotebookRead", "TodoWrite", "ToolSearch"]);
28const EDIT_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]);
29const SHELL_TOOLS = new Set(["Bash", "PowerShell"]);
30const DESTRUCTIVE = [
31 /\brm\s+(-[a-zA-Z]*[rRf][a-zA-Z]*\s+)+/,
32 /\brm\s+--(recursive|force)\b/,
33 /\bgit\s+reset\s+--hard\b/,
34 /\bgit\s+push\b[^|;&]*\s(--force\b|--force-with-lease\b|-f\b)/,
35 /\bgit\s+clean\s+-[a-zA-Z]*f/,
36 /\bgit\s+checkout\s+(--\s+)?\.\s*$/,
37 /\bgit\s+branch\s+-D\b/,
38 /\bdrop\s+(table|database|schema)\b/i,
39 /\btruncate\s+table\b/i,
40 /\bdelete\s+from\s+\w+\s*(;|$)/i,
41 /\bmkfs(\.\w+)?\b/,
42 /\bdd\s+if=/,
43 /\bchmod\s+-R\s+777\b/,
44 /:\(\)\s*\{\s*:\|:&\s*\};:/,
45 />\s*\/dev\/(sd|disk|nvme)/,
46];
47
48const CHIPS = {
49 read: { label: " READ ", fg: C.sage, bg: C.sageBg, bold: false, rule: C.sage },
50 edit: { label: " EDIT ", fg: C.ink, bg: C.coral, bold: true, rule: C.coral },
51 bash: { label: " BASH ", fg: C.ink, bg: C.amber, bold: true, rule: C.amber },
52 danger: { label: " ! RISK ", fg: C.paper, bg: C.red, bold: true, rule: C.red },
53 other: { label: " TOOL ", fg: C.cream, bg: "#2a2725", bold: false, rule: C.muted },
54};
55const RANK = { other: 0, read: 1, bash: 2, edit: 3, danger: 4 };
56
57const VERBS = {
58 thinking: "Inking",
59 requesting: "Sketching",
60 responding: "Writing",
61 "tool-use": "Working",
62 "tool-input": "Drafting",
63};
64const PULSE = ["✳", "✴", "✶", "✴"];
65
66let ticker = null; // a timer handle; a reload drops it with the old environment
67
68export function register(on) {
69 on("session.start", async ($, e, next) => {
70 const result = await next(e);
71 const saved = await $.store.get(STORE_KEY);
72 await $.state.set(prefsRef, normalizePrefs(saved));
73 await $.state.set(turnRef, EMPTY_TURN);
74 await $.command.register({
75 name: "skin",
76 description: "Coral skin: show status, or turn it on, off, or toggle risk colors",
77 argumentHint: "[on|off|risk]",
78 immediate: true,
79 });
80 return result;
81 });
82
83 on("command.run", { command: "skin" }, async ($, e) => {
84 const { value: current } = await $.state.get(prefsRef);
85 const prefs = normalizePrefs(current);
86 const arg = (e.args || "").trim().toLowerCase();
87 let nextPrefs = prefs;
88 if (arg === "on") nextPrefs = { ...prefs, enabled: true };
89 else if (arg === "off") nextPrefs = { ...prefs, enabled: false };
90 else if (arg === "risk") nextPrefs = { ...prefs, risk: !prefs.risk };
91 else if (arg === "risk on") nextPrefs = { ...prefs, risk: true };
92 else if (arg === "risk off") nextPrefs = { ...prefs, risk: false };
93 else if (arg !== "" && arg !== "status") {
94 return { text: `Unknown option "${arg}". Use /skin, /skin on, /skin off or /skin risk.` };
95 }
96 if (nextPrefs !== prefs) {
97 await $.state.set(prefsRef, nextPrefs);
98 await $.store.set(STORE_KEY, nextPrefs);
99 $.ui.invalidate("ui.render");
100 }
101 return { text: statusLine(nextPrefs) };
102 });
103
104 // Turn bookkeeping for the spinner and the hint tail.
105 on("turn.start", async ($, e, next) => {
106 const result = await next(e);
107 const now = await $.clock.now();
108 await $.state.set(turnRef, { ...EMPTY_TURN, active: true, startedAt: now, now });
109 if (ticker) ticker.cancel();
110 ticker = $.clock.every(500, () => {
111 void (async () => {
112 const at = await $.clock.now();
113 await update($, turnRef, (t) => ({ ...(t || EMPTY_TURN), now: at, frame: ((t && t.frame) || 0) + 1 }));
114 })();
115 });
116 return result;
117 });
118
119 on("turn.complete", async ($, e, next) => {
120 if (ticker) {
121 ticker.cancel();
122 ticker = null;
123 }
124 const now = await $.clock.now();
125 await update($, turnRef, (t) => ({ ...(t || EMPTY_TURN), active: false, now }));
126 return next(e);
127 });
128
129 on("tool.call", async ($, e, next) => {
130 const kind = riskOf(e.tool, e);
131 const field = kind === "danger" ? "danger" : kind === "edit" ? "edits" : kind === "bash" ? "bash" : kind === "read" ? "reads" : "other";
132 await update($, turnRef, (t) => {
133 const base = t || EMPTY_TURN;
134 return { ...base, [field]: (base[field] || 0) + 1 };
135 });
136 return next(e);
137 });
138
139 // Spinner: coral pulse, mode verb, elapsed time and tool count.
140 on("ui.render", { component: "Spinner" }, async ($, e, next) => {
141 const { value: p } = await $.state.get(prefsRef);
142 if (!normalizePrefs(p).enabled) return next(e);
143 const { value: t } = await $.state.get(turnRef);
144 const turn = t || EMPTY_TURN;
145 const tools = toolCount(turn);
146 if (e.surface !== "terminal") {
147 const suffix = tools > 0 ? ` ✳ ${tools} tool${tools === 1 ? "" : "s"}` : " ✳";
148 return next({ ...e, props: { ...e.props, suffix } });
149 }
150 const { Box, Text } = $.ui.resolve(e);
151 const verb = e.props.message || VERBS[e.props.mode] || e.props.word || "Working";
152 const seconds = turn.startedAt ? Math.max(0, Math.floor((turn.now - turn.startedAt) / 1000)) : 0;
153 return spinnerTree(Box, Text, { verb, frame: turn.frame, seconds, tools, danger: turn.danger, edits: turn.edits });
154 });
155
156 // Tool rows: a risk chip in front of the engine's own row.
157 on("ui.render", { component: "ToolUse" }, async ($, e, next) => {
158 const { value: p } = await $.state.get(prefsRef);
159 const prefs = normalizePrefs(p);
160 if (!prefs.enabled || !prefs.risk) return next(e);
161 const kind = riskOf(e.props.tool, e.props.input);
162 if (kind === "other") return next(e); // neutral tools (questions, agents, MCP) keep the stock row
163 const original = await next(e);
164 if (!original) return original;
165 const { Box, Text } = $.ui.resolve(e);
166 return chipRow(Box, Text, kind, original, null);
167 });
168
169 on("ui.render", { component: "ToolGroup" }, async ($, e, next) => {
170 const { value: p } = await $.state.get(prefsRef);
171 const prefs = normalizePrefs(p);
172 if (!prefs.enabled || !prefs.risk) return next(e);
173 const original = await next(e);
174 if (!original || e.props.isExpanded) return original;
175 const { Box, Text } = $.ui.resolve(e);
176 let top = "other";
177 for (const call of e.props.calls) {
178 const kind = riskOf(call.tool, call.input);
179 if (RANK[kind] > RANK[top]) top = kind;
180 }
181 if (top === "other") return original;
182 return chipRow(Box, Text, top, original, e.props.calls.length);
183 });
184
185 // The person's prompt: a card with a coral rule.
186 on("ui.render", { component: "UserMessage" }, async ($, e, next) => {
187 const { value: p } = await $.state.get(prefsRef);
188 if (!normalizePrefs(p).enabled) return next(e);
189 const { Box, Text } = $.ui.resolve(e);
190 if (e.props.origin.kind !== "composer") {
191 const original = await next(e);
192 if (!original) return original;
193 return Box({ flexDirection: "row", children: [Text({ color: C.coralDim, children: "▎" }), original] });
194 }
195 const paper = e.surface !== "terminal";
196 const bg = paper ? C.paper : C.card;
197 const fg = paper ? C.ink : C.cream;
198 return Box({
199 flexDirection: "row",
200 backgroundColor: bg,
201 paddingRight: 1,
202 children: [
203 Text({ color: C.coral, backgroundColor: bg, bold: true, children: "▌ ❯ " }),
204 Text({ color: fg, backgroundColor: bg, wrap: "wrap", children: e.props.text }),
205 ],
206 });
207 });
208
209 // Assistant replies: the engine's markdown, behind a coral rule.
210 on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
211 const { value: p } = await $.state.get(prefsRef);
212 const original = await next(e);
213 if (!normalizePrefs(p).enabled || !original) return original;
214 const { Box } = $.ui.resolve(e);
215 return Box({
216 flexDirection: "row",
217 children: [
218 Box({ width: 1, flexShrink: 0, backgroundColor: e.props.isFirstOfReply ? C.coral : C.coralDim }),
219 Box({ flexDirection: "column", flexGrow: 1, flexShrink: 1, paddingLeft: 1, children: [original] }),
220 ],
221 });
222 });
223
224 // Turn footer (terminal only): coral glyph, cream words, a coral rule.
225 on("ui.render", { component: "TurnDuration" }, async ($, e, next) => {
226 const { value: p } = await $.state.get(prefsRef);
227 if (!normalizePrefs(p).enabled) return next(e);
228 const { Box, Text } = $.ui.resolve(e);
229 return Box({
230 flexDirection: "row",
231 children: [
232 Text({ color: C.coral, bold: true, children: "✳ " }),
233 Text({ color: C.cream, children: `${e.props.word} for ${duration(e.props.durationMs)}` }),
234 Text({ color: C.coralDim, wrap: "truncate", children: " ──────────────────" }),
235 ],
236 });
237 });
238
239 // Hint line: keep the engine's line and its pills, add a risk tally.
240 on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
241 const { value: p } = await $.state.get(prefsRef);
242 if (!normalizePrefs(p).enabled) return next(e);
243 const { value: t } = await $.state.get(turnRef);
244 return next({ ...e, props: { ...e.props, tail: hintTail(t || EMPTY_TURN) } });
245 });
246
247 on("ui.render", { component: "SessionMode" }, async ($, e, next) => {
248 const { value: p } = await $.state.get(prefsRef);
249 if (!normalizePrefs(p).enabled) return next(e);
250 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, "coral skin"] } });
251 });
252
253 // Questions: the engine's dialog, untouched, inside a coral frame.
254 on("ui.render", { component: "AskUserQuestion" }, async ($, e, next) => {
255 const { value: p } = await $.state.get(prefsRef);
256 const original = await next(e);
257 if (!normalizePrefs(p).enabled || !original) return original;
258 const { Box, Text } = $.ui.resolve(e);
259 // No side border: the dialog sizes its rules to the full width and would wrap inside one.
260 return Box({
261 flexDirection: "column",
262 children: [
263 Box({
264 flexDirection: "row",
265 children: [
266 Text({ color: C.ink, backgroundColor: C.coral, bold: true, children: " ✳ CLAUDE IS ASKING " }),
267 Text({ color: C.coral, wrap: "truncate", children: " " + "─".repeat(60) }),
268 ],
269 }),
270 original,
271 ],
272 });
273 });
274
275 // The /skin command's own output row.
276 on("ui.render", { component: "CommandOutput", props: { command: "skin" } }, async ($, e, next) => {
277 if (e.props.isErrored) return next(e);
278 const { Box, Text } = $.ui.resolve(e);
279 return Box({
280 flexDirection: "row",
281 children: [
282 Text({ color: C.ink, backgroundColor: C.coral, bold: true, children: " ✳ CORAL SKIN " }),
283 Text({ color: e.surface === "terminal" ? C.cream : undefined, children: ` ${e.props.text.replace(/^coral-skin:\s*/, "")}` }),
284 ],
285 });
286 });
287}
288
289function normalizePrefs(v) {
290 const o = v && typeof v === "object" ? v : {};
291 return {
292 enabled: typeof o.enabled === "boolean" ? o.enabled : DEFAULT_PREFS.enabled,
293 risk: typeof o.risk === "boolean" ? o.risk : DEFAULT_PREFS.risk,
294 };
295}
296
297function statusLine(prefs) {
298 const skin = prefs.enabled ? "on" : "off (stock rendering)";
299 const risk = prefs.risk ? "on" : "off";
300 return `skin ${skin} · risk colors ${risk}. Try /skin on, /skin off, /skin risk.`;
301}
302
303export function riskOf(tool, input) {
304 if (EDIT_TOOLS.has(tool)) return "edit";
305 if (READ_TOOLS.has(tool)) return "read";
306 if (SHELL_TOOLS.has(tool)) {
307 const command = input && typeof input.command === "string" ? input.command : "";
308 return DESTRUCTIVE.some((re) => re.test(command)) ? "danger" : "bash";
309 }
310 return "other";
311}
312
313function chipRow(Box, Text, kind, original, count) {
314 const chip = CHIPS[kind];
315 const label = count && count > 1 ? `${chip.label.trimEnd()} ×${count} ` : chip.label;
316 // The engine's row opens with a blank margin line; the chip sits beside the header under it.
317 return Box({
318 flexDirection: "row",
319 children: [
320 Box({
321 flexShrink: 0,
322 marginTop: 1,
323 children: [Text({ color: chip.fg, backgroundColor: chip.bg, bold: chip.bold, wrap: "truncate", children: label.padEnd(10) })],
324 }),
325 Box({ flexDirection: "column", flexGrow: 1, flexShrink: 1, children: [original] }),
326 ],
327 });
328}
329
330function spinnerTree(Box, Text, s) {
331 const glyph = PULSE[s.frame % PULSE.length];
332 const bright = s.frame % 2 === 0;
333 const parts = [
334 Text({ color: bright ? C.coral : C.coralDim, bold: true, children: `${glyph} ` }),
335 Text({ color: C.coral, bold: true, children: `${s.verb}…` }),
336 Text({ color: C.cream, children: ` ${duration(s.seconds * 1000)}` }),
337 ];
338 if (s.tools > 0) parts.push(Text({ color: C.muted, children: ` · ${s.tools} tool${s.tools === 1 ? "" : "s"}` }));
339 if (s.edits > 0) parts.push(Text({ color: C.coral, children: ` · ${s.edits} edit${s.edits === 1 ? "" : "s"}` }));
340 if (s.danger > 0) parts.push(Text({ color: C.paper, backgroundColor: C.red, bold: true, children: ` ! ${s.danger} risky ` }));
341 return Box({ flexDirection: "row", children: parts });
342}
343
344function toolCount(t) {
345 return (t.reads || 0) + (t.edits || 0) + (t.bash || 0) + (t.danger || 0) + (t.other || 0);
346}
347
348function hintTail(t) {
349 const bits = [];
350 if (t.reads) bits.push(`${t.reads} read`);
351 if (t.edits) bits.push(`${t.edits} edit`);
352 if (t.bash) bits.push(`${t.bash} bash`);
353 if (t.danger) bits.push(`${t.danger} risky`);
354 return bits.length ? `✳ coral · ${bits.join(" · ")}` : "✳ coral";
355}
356
357function duration(ms) {
358 const total = Math.max(0, Math.round(ms / 1000));
359 const m = Math.floor(total / 60);
360 const s = total % 60;
361 return m > 0 ? `${m}m ${s}s` : `${s}s`;
362}
363types/index.d.ts 19 lines1export type CoralSkinPrefs = { enabled: boolean; risk: boolean };
2export type CoralSkinTurn = {
3 active: boolean;
4 startedAt: number;
5 now: number;
6 frame: number;
7 reads: number;
8 edits: number;
9 bash: number;
10 danger: number;
11 other: number;
12};
13
14declare module "claude-code" {
15 interface PluginState {
16 "coral-skin": { prefs: CoralSkinPrefs; turn: CoralSkinTurn };
17 }
18}
19