Shared widgets for Claude Code mods: other plugins hand $.kit their state and draw what it returns.

A Claude Code plugin that gives every mod a $.kit: hand it widgets, draw what it returns. Plugins that use it look and behave the same, and the kit owns the small interactions (a long list folds behind +3 more).
A mod that must stand alone can instead copy the widget code in with npx @aeriondyseti/plugin-kit add-kit --vendor; see the package README.
plugin-kit is listed in the aeriondyseti-plugins marketplace:
claude plugin marketplace add aeriondyseti/aeriondyseti-plugins
claude plugin install plugin-kit@aeriondyseti-plugins
.claude-plugin/plugin.json: { "name": "my-mod", "dependencies": [{ "name": "plugin-kit", "marketplace": "aeriondyseti-plugins" }] }
A bare "plugin-kit" would be looked up in your plugin's marketplace, so name ours. (A plugin listed in aeriondyseti-plugins itself can write just "plugin-kit".) For installing your plugin to install the kit too, your marketplace's marketplace.json must allow it:
{ "name": "your-marketplace", "allowCrossMarketplaceDependenciesOn": ["aeriondyseti-plugins"], ... }
Without that line, your users install plugin-kit themselves first (the allowlist doesn't apply to a dependency that is already installed); otherwise your plugin installs but doesn't load, and claude plugin list says Dependency "plugin-kit@aeriondyseti-plugins" is not installed.
Claude Code lays the kit's contract into your .claude-plugin/types/plugin-kit/, so $.kit is typed.
Steps 1 and 2 are one command, run in your plugin's folder:
npx @aeriondyseti/plugin-kit add-kit
hydrate. A mod can't import code from another plugin, and functions can't cross between plugins, so $.kit.render returns a plain JSON description. add-kit copies hydrate.ts (one self-contained file) beside your hooks module; run it again after upgrading to refresh the copy. It builds the elements and gives every Button its onPress. import { hydrate } from './hydrate.ts'
on('ui.render', { component: 'Pane', requestId: 'stats' }, async ($, e, next) => {
const tree = await $.kit.render({
id: `my-mod:${e.requestId}`,
widgets: {
Health: { type: 'meter', value: 88, max: 100, color: 'red', group: 'Body' },
Suspicion: { type: 'clock', value: 2, of: 6, note: 'The clerk heard something.' },
Clues: { type: 'list', value: ['a torn ticket', 'wet boots'] },
},
})
const { Box } = $.ui.resolve(e)
return <Box>{hydrate(tree, h)}</Box>
})
The drawing redraws by itself when the kit's view state changes (a list folded or unfolded). Give id something unique to the drawing: it keys that state and prefixes the kit's Button keys.
$.kit| Method | Gives |
|---|---|
render({ id, widgets, barWidth?, listLimit?, glyphs? }) | A UI description of the widgets, for hydrate. Invalid widgets draw as text. |
line({ widgets }) | One line of plain text, joined with · : for a status line. |
parse({ name, widget }) | { ok, widget } or { ok: false, error }, the error naming the widget and the fix. |
catalog() | { table, schema }: the types as a markdown table for a prompt or skill, and a JSON Schema for one widget as a tool input. |
The widget types are documented in types/index.d.ts.
Buttons you add to the tree get their handler by key: hydrate(tree, h, { save: () => ... }). Keys starting kit: are the kit's.
The person using Claude Code sets plugin-kit's look once, for every plugin that draws through it, in /config (or claude plugin configure plugin-kit):
| Setting | Default | |
|---|---|---|
glyphs | unicode | ascii draws bars as ###---, clocks as **.., bullets as - |
barWidth | 10 | cells in a meter's bar |
listLimit | 5 | items a list shows before folding |
A plugin that passes barWidth, listLimit or glyphs to render overrides them for its own drawing; most shouldn't.
Every $.kit method is also an event, so a theme plugin can rewrite what every caller asks for, e.g. on('kit.render', ($, e, next) => next({ ...e, barWidth: 20 })).
The widget code lives in ../src/widgets and is copied into hooks/kit/ (a mod imports only its own files). After changing it:
npm run plugin:sync # refresh hooks/kit/
npm run plugin:check # claude plugin validate + claude plugin test
npm test fails if hooks/kit/ is stale, and npm run typecheck fails if types/index.d.ts drifts from the widget types.
hooks/register.ts 65 lines1// plugin-kit: adds `$.kit` to every mod's `$`, so plugins that list
2// "plugin-kit" under `dependencies` draw widgets the same way.
3//
4// Everything crossing `$.kit` is plain JSON (functions cannot cross between
5// plugins), so `render` returns a UI description, not elements; the caller
6// turns it into elements with `hydrate`. The kit still owns its own buttons:
7// every Button gets an `onPress`, a press raises `ui.press`, and the hook
8// below answers the presses on keys the kit made.
9
10import { atom, update } from 'claude-code';
11import type { Register } from 'claude-code';
12import type { Kit, KitJson } from '../types';
13import { widgetJsonSchema, widgetTable } from './kit/catalog.ts';
14import { describeWidgets, parseWidgetKey } from './kit/describe.ts';
15import { renderWidgetsLine } from './kit/line.ts';
16import { loadWidgets, parseWidget } from './kit/parse.ts';
17
18const expanded = atom({ plugin: 'plugin-kit', key: 'expanded' } as const, {});
19
20export const register: Register = (on, options) => {
21 // The user's settings (userConfig), one look for every plugin using the
22 // kit. A caller's own arguments still win for its drawing.
23 const defaults = {
24 glyphs: options.glyphs === 'ascii' ? 'ascii' : 'unicode',
25 barWidth: typeof options.barWidth === 'number' ? options.barWidth : 10,
26 listLimit: typeof options.listLimit === 'number' ? options.listLimit : 5,
27 } as const;
28
29 on('engine.create', async ($, e, next) => {
30 // At engine.create `$` is empty; the methods reach the engine through
31 // what `next` built, always spelled `built.<noun>.<method>(...)`.
32 const built = await next(e);
33 const kit: Kit = {
34 render: async ({ id, widgets, barWidth, listLimit, glyphs }) => {
35 const state = await built.state.get({ plugin: 'plugin-kit', key: 'expanded' } as const);
36 return describeWidgets(loadWidgets(widgets).widgets, {
37 id,
38 expanded: state.value?.[id] ?? [],
39 barWidth: barWidth ?? defaults.barWidth,
40 listLimit: listLimit ?? defaults.listLimit,
41 glyphs: glyphs ?? defaults.glyphs,
42 });
43 },
44 line: async ({ widgets }) => renderWidgetsLine(loadWidgets(widgets).widgets),
45 parse: async ({ name, widget }) => parseWidget(name, widget),
46 catalog: async () => ({
47 table: widgetTable(),
48 schema: widgetJsonSchema() as { [key: string]: KitJson },
49 }),
50 };
51 return { ...built, kit };
52 });
53
54 // Fold and unfold lists. A press on any other key goes on untouched.
55 on('ui.press', async ($, e, next) => {
56 const hit = parseWidgetKey(e.element);
57 if (!hit) return next(e);
58 await update($, expanded, (all) => {
59 const open = (all[hit.id] ?? []).filter((name) => name !== hit.name);
60 return { ...all, [hit.id]: hit.action === 'more' ? [...open, hit.name] : open };
61 });
62 return { element: e.element };
63 });
64};
65hooks/kit/catalog.ts 80 lines1// Generated from src/widgets/catalog.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * The widget catalog, written for a model to read: which type fits which
5 * job. Both the markdown table (for a skill or prompt) and the JSON Schema
6 * (for a tool's input) are generated from it, so they cannot drift apart.
7 */
8
9import { COLORS } from './vocab.ts';
10import { MAX_CLOCK_SEGMENTS, WIDGET_TYPE_NAMES, type WidgetType } from './types.ts';
11
12export interface WidgetTypeInfo {
13 /** The type's own fields, as a model should write them. */
14 fields: string;
15 /** When to pick it, with examples. */
16 use: string;
17}
18
19export const WIDGET_CATALOG: Record<WidgetType, WidgetTypeInfo> = {
20 text: {
21 fields: 'value: a few words',
22 use: 'A state in words that changes: the weather, a disguise, the current branch',
23 },
24 counter: {
25 fields: 'value: a number',
26 use: 'A number with no ceiling: days to a deadline, coins owed, retries',
27 },
28 meter: {
29 fields: 'value, max: numbers',
30 use: 'A number out of a known maximum, drawn as a bar: health, fuel, context used',
31 },
32 clock: {
33 fields: 'value, of: whole numbers',
34 use: `Something that happens when it fills, drawn as segments (4, 6 or 8; at most ${MAX_CLOCK_SEGMENTS}): suspicion, a ritual, a countdown`,
35 },
36 list: {
37 fields: 'value: short items',
38 use: 'Things gathered or learned, one per line: clues, allies, open todos',
39 },
40 tags: {
41 fields: 'value: one or two words each',
42 use: 'Conditions that come and go, on one line: wounded, hunted, blocked',
43 },
44};
45
46/** The catalog as a markdown table: `| type | fields | when |`. */
47export function widgetTable(): string {
48 const rows = WIDGET_TYPE_NAMES.map((t) => `| \`${t}\` | ${WIDGET_CATALOG[t].fields} | ${WIDGET_CATALOG[t].use} |`);
49 return ['| type | fields | when |', '|---|---|---|', ...rows].join('\n');
50}
51
52/**
53 * JSON Schema for one widget, as a tool's input property. Flat rather than
54 * one branch per type: a model reads the descriptions, and `parseWidget`
55 * enforces the per-type rules with errors that name the fix.
56 */
57export function widgetJsonSchema(): Record<string, unknown> {
58 return {
59 type: 'object',
60 properties: {
61 type: {
62 type: 'string',
63 enum: [...WIDGET_TYPE_NAMES],
64 description: WIDGET_TYPE_NAMES.map((t) => `${t}: ${WIDGET_CATALOG[t].use}`).join('. '),
65 },
66 value: {
67 description: 'text: a few words. counter, meter, clock: a number. list, tags: an array of short strings.',
68 anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'array', items: { type: 'string' } }],
69 },
70 max: { type: 'number', description: 'meter only: the value the bar is out of' },
71 of: { type: 'integer', minimum: 1, maximum: MAX_CLOCK_SEGMENTS, description: 'clock only: how many segments' },
72 note: { type: 'string', description: 'Optional: one to three short sentences shown dim under the row' },
73 color: { type: 'string', description: `Optional: a hex like "#c0392b" or one of ${COLORS.join(', ')}` },
74 group: { type: 'string', description: 'Optional: a heading shared by neighbouring widgets' },
75 },
76 required: ['type', 'value'],
77 additionalProperties: false,
78 };
79}
80hooks/kit/describe.ts 135 lines1// Generated from src/widgets/describe.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Widgets as a UI description: plain JSON shaped like the element trees
5 * Claude Code's mods draw (`{ type: 'Text', props, children }`).
6 *
7 * Plain data, not elements, because it crosses between plugins: the
8 * plugin-kit mod returns it from `$.kit.render`, and functions cannot cross.
9 * The drawing plugin turns it into elements with `hydrate`, which is also
10 * where every Button gets its `onPress`.
11 */
12
13import { ICONS } from './icons.ts';
14import type { Widget, Widgets } from './types.ts';
15
16export type UiProp = string | number | boolean;
17export type UiNode = string | UiElement;
18export interface UiElement {
19 type: 'Box' | 'Text' | 'Button';
20 props?: Record<string, UiProp>;
21 children?: UiNode[];
22}
23
24/** What a Button in a description does when pressed. */
25export type WidgetAction = 'more' | 'less';
26
27/** Which characters draw bars, segments, bullets and separators. */
28export type WidgetGlyphs = 'unicode' | 'ascii';
29
30/** The characters each glyph set draws with; shared by every renderer. */
31export const GLYPHS: Record<WidgetGlyphs, { full: string; empty: string; on: string; off: string; bullet: string; dot: string }> = {
32 unicode: { full: '▰', empty: '▱', on: '◆', off: '◇', bullet: ICONS.bullet, dot: ICONS.dot },
33 ascii: { full: '#', empty: '-', on: '*', off: '.', bullet: '-', dot: '|' },
34};
35
36export interface DescribeOptions {
37 /**
38 * Names this drawing: it prefixes every Button key, so presses (and any
39 * view state keyed by it) never collide with another drawing's.
40 */
41 id: string;
42 /** Cells in a meter's bar. Default 10. */
43 barWidth?: number;
44 /** Items a list shows before folding the rest behind a button. Default 5. */
45 listLimit?: number;
46 /** Names of the lists drawn unfolded. */
47 expanded?: readonly string[];
48 /** Default `unicode`; `ascii` for surfaces or fonts without the shapes. */
49 glyphs?: WidgetGlyphs;
50}
51
52const KEY_PREFIX = 'kit:';
53
54/** The Button key for `action` on widget `name` in drawing `id`. */
55export function widgetKey(id: string, name: string, action: WidgetAction): string {
56 return `${KEY_PREFIX}${encodeURIComponent(id)}:${encodeURIComponent(name)}:${action}`;
57}
58
59/** The inverse of `widgetKey`; undefined for any key it did not make. */
60export function parseWidgetKey(key: string): { id: string; name: string; action: WidgetAction } | undefined {
61 if (!key.startsWith(KEY_PREFIX)) return undefined;
62 const [id, name, action, ...rest] = key.slice(KEY_PREFIX.length).split(':');
63 if (id === undefined || name === undefined || rest.length > 0) return undefined;
64 if (action !== 'more' && action !== 'less') return undefined;
65 return { id: decodeURIComponent(id), name: decodeURIComponent(name), action };
66}
67
68export function describeWidgets(widgets: Widgets, opts: DescribeOptions): UiElement {
69 const children: UiNode[] = [];
70 let group: string | undefined;
71 for (const [name, widget] of Object.entries(widgets)) {
72 if (widget.group && widget.group !== group) {
73 children.push(text(widget.group, { dimColor: true }));
74 }
75 group = widget.group;
76 children.push(...describeWidget(name, widget, opts));
77 }
78 return box(children, { key: `${KEY_PREFIX}${encodeURIComponent(opts.id)}`, flexDirection: 'column' });
79}
80
81function describeWidget(name: string, widget: Widget, opts: DescribeOptions): UiNode[] {
82 const paint: Record<string, UiProp> = widget.color ? { color: widget.color } : {};
83 const label = text(name, { bold: true, ...paint });
84 const note = widget.note ? [text(widget.note, { dimColor: true, wrap: 'wrap' })] : [];
85 const row = (...rest: UiNode[]) => box([label, ...rest], { flexDirection: 'row', gap: 1 });
86 const g = GLYPHS[opts.glyphs ?? 'unicode'];
87
88 switch (widget.type) {
89 case 'text':
90 case 'counter':
91 return [row(text(String(widget.value))), ...note];
92 case 'meter': {
93 const cells = opts.barWidth ?? 10;
94 const filled = Math.round((widget.value / widget.max) * cells);
95 const bar = g.full.repeat(filled) + g.empty.repeat(cells - filled);
96 return [row(text(bar, paint), text(`${widget.value}/${widget.max}`)), ...note];
97 }
98 case 'clock': {
99 const segments = g.on.repeat(widget.value) + g.off.repeat(widget.of - widget.value);
100 return [row(text(segments, paint)), ...note];
101 }
102 case 'tags':
103 return [row(text(widget.value.join(` ${g.dot} `) || '(none)', paint)), ...note];
104 case 'list':
105 return [label, ...describeItems(name, widget.value, paint, opts), ...note];
106 }
107}
108
109function describeItems(name: string, items: string[], paint: Record<string, UiProp>, opts: DescribeOptions): UiNode[] {
110 const limit = opts.listLimit ?? 5;
111 const isOpen = opts.expanded?.includes(name) ?? false;
112 const shown = isOpen ? items : items.slice(0, limit);
113 const bullet = GLYPHS[opts.glyphs ?? 'unicode'].bullet;
114 const lines: UiNode[] = shown.map((item) => text(` ${bullet} ${item}`, paint));
115 if (items.length === 0) lines.push(text(' (none)', { dimColor: true }));
116 if (items.length > limit) {
117 const action = isOpen ? 'less' : 'more';
118 const label = isOpen ? 'less' : `+${items.length - limit} more`;
119 lines.push(box([button(widgetKey(opts.id, name, action), label)], { paddingLeft: 2 }));
120 }
121 return lines;
122}
123
124function box(children: UiNode[], props: Record<string, UiProp>): UiElement {
125 return { type: 'Box', props, children };
126}
127
128function text(value: string, props: Record<string, UiProp> = {}): UiElement {
129 return { type: 'Text', props, children: [value] };
130}
131
132function button(key: string, label: string): UiElement {
133 return { type: 'Button', props: { key, label } };
134}
135hooks/kit/line.ts 33 lines1// Generated from src/widgets/line.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * The short form of a widget: one line of plain text, as a status line or a
5 * prompt shows it. `Health 88/100`, `Suspicion 2/6`, `Powers: wheel, parry`.
6 */
7
8import { ICONS } from './icons.ts';
9import type { Widget, Widgets } from './types.ts';
10
11export function renderWidgetLine(name: string, widget: Widget): string {
12 switch (widget.type) {
13 case 'text':
14 case 'counter':
15 return `${name} ${widget.value}`;
16 case 'meter':
17 return `${name} ${widget.value}/${widget.max}`;
18 case 'clock':
19 return `${name} ${widget.value}/${widget.of}`;
20 case 'list':
21 return `${name}: ${widget.value.join(', ') || '(none)'}`;
22 case 'tags':
23 return `${name}: ${widget.value.join(` ${ICONS.dot} `) || '(none)'}`;
24 }
25}
26
27/** Every widget's line, joined with ` · `. */
28export function renderWidgetsLine(widgets: Widgets): string {
29 return Object.entries(widgets)
30 .map(([name, widget]) => renderWidgetLine(name, widget))
31 .join(` ${ICONS.dot} `);
32}
33hooks/kit/parse.ts 150 lines1// Generated from src/widgets/parse.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Validation for widgets as a model or a file writes them.
5 *
6 * A bad widget is an expected outcome here, not an exception: the caller is
7 * often a model that reads the error and retries, so `parseWidget` returns a
8 * result and every error names the widget and the fix.
9 */
10
11import { COLORS } from './vocab.ts';
12import {
13 MAX_CLOCK_SEGMENTS,
14 WIDGET_TYPE_NAMES,
15 type Widget,
16 type WidgetColor,
17 type WidgetCommon,
18 type Widgets,
19} from './types.ts';
20
21export type WidgetParseResult = { ok: true; widget: Widget } | { ok: false; error: string };
22
23export interface WidgetLoad {
24 widgets: Widgets;
25 warnings: string[];
26}
27
28const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
29
30export function parseWidget(name: string, raw: unknown): WidgetParseResult {
31 const fail = (message: string): WidgetParseResult => ({ ok: false, error: `${name}: ${message}` });
32 if (!isRecord(raw)) return fail('a widget is an object with a type and a value');
33
34 const type = WIDGET_TYPE_NAMES.find((t) => t === raw.type);
35 if (!type) {
36 const given = raw.type === undefined ? 'needs a type' : `type "${String(raw.type)}" is unknown`;
37 return fail(`${given}: one of ${WIDGET_TYPE_NAMES.join(', ')}`);
38 }
39
40 const common: WidgetCommon = {};
41 for (const key of ['note', 'group'] as const) {
42 const v = raw[key];
43 if (v === undefined || v === null) continue;
44 if (typeof v !== 'string') return fail(`${key} must be text`);
45 if (v.trim()) common[key] = v.trim();
46 }
47 if (raw.color !== undefined && raw.color !== null && raw.color !== '') {
48 const color = parseColor(raw.color);
49 if (!color) return fail(`color must be a hex like "#c0392b" or one of ${COLORS.join(', ')}`);
50 common.color = color;
51 }
52 if (type !== 'meter' && raw.max !== undefined && raw.max !== null) {
53 return fail(`a ${type} takes no max: use a meter for a value out of a maximum`);
54 }
55 if (type !== 'clock' && raw.of !== undefined && raw.of !== null) {
56 return fail(`a ${type} takes no of: use a clock for segments that fill`);
57 }
58
59 const value = raw.value;
60 switch (type) {
61 case 'text':
62 if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
63 return { ok: true, widget: { type, value: String(value).trim(), ...common } };
64 }
65 return fail('text needs value: a few words');
66 case 'counter':
67 if (!isNumber(value)) return fail('counter needs a number value: put words in a text widget');
68 return { ok: true, widget: { type, value, ...common } };
69 case 'meter': {
70 const max = raw.max;
71 if (!isNumber(max)) return fail('meter needs max: the value is drawn as a bar out of it');
72 if (max <= 0) return fail('meter max must be above 0');
73 if (!isNumber(value)) return fail(`meter needs a number value, out of max ${max}`);
74 if (value < 0 || value > max) {
75 return fail(`meter value ${value} is outside 0 to ${max}: change the value or the max`);
76 }
77 return { ok: true, widget: { type, value, max, ...common } };
78 }
79 case 'clock': {
80 const of = raw.of;
81 if (!isNumber(of)) return fail('clock needs of: the number of segments, usually 4, 6 or 8');
82 if (!Number.isInteger(of) || of < 1 || of > MAX_CLOCK_SEGMENTS) {
83 return fail(`clock of must be a whole number from 1 to ${MAX_CLOCK_SEGMENTS}`);
84 }
85 if (!isNumber(value) || !Number.isInteger(value)) {
86 return fail(`clock needs value: how many of its ${of} segments are filled`);
87 }
88 if (value < 0 || value > of) return fail(`clock value ${value} is outside 0 to ${of}`);
89 return { ok: true, widget: { type, value, of, ...common } };
90 }
91 case 'list':
92 case 'tags': {
93 const items = typeof value === 'string' ? [value] : value;
94 if (!Array.isArray(items) || items.some((i) => typeof i === 'object' && i !== null)) {
95 return fail(`${type} needs value: a list of short items`);
96 }
97 const clean = items.map((i) => String(i ?? '').trim()).filter(Boolean);
98 return { ok: true, widget: { type, value: clean, ...common } };
99 }
100 }
101}
102
103/**
104 * Widgets from somewhere that must not fail to open (a file on disk, a
105 * plugin's stored state). An invalid entry becomes a text widget holding its
106 * raw value, with a warning saying what to fix.
107 */
108export function loadWidgets(raw: unknown): WidgetLoad {
109 if (raw === undefined || raw === null) return { widgets: {}, warnings: [] };
110 if (!isRecord(raw)) return { widgets: {}, warnings: ['widgets should map each name to a widget; ignored'] };
111
112 const widgets: Widgets = {};
113 const warnings: string[] = [];
114 for (const [name, entry] of Object.entries(raw)) {
115 const parsed = parseWidget(name, entry);
116 if (parsed.ok) {
117 widgets[name] = parsed.widget;
118 continue;
119 }
120 warnings.push(`${parsed.error} (shown as text until fixed)`);
121 const value = isRecord(entry) ? entry.value : entry;
122 const note = isRecord(entry) && typeof entry.note === 'string' ? entry.note.trim() : '';
123 widgets[name] = { type: 'text', value: stringify(value), ...(note ? { note } : {}) };
124 }
125 return { widgets, warnings };
126}
127
128function parseColor(raw: unknown): WidgetColor | undefined {
129 if (typeof raw !== 'string') return undefined;
130 const color = raw.trim();
131 if (HEX.test(color)) return color as `#${string}`;
132 return COLORS.find((c) => c === color.toLowerCase());
133}
134
135function stringify(value: unknown): string {
136 if (value === undefined || value === null) return '';
137 if (typeof value === 'string') return value;
138 if (typeof value === 'number' || typeof value === 'boolean') return String(value);
139 if (Array.isArray(value)) return value.map(stringify).join(', ');
140 return JSON.stringify(value);
141}
142
143function isNumber(value: unknown): value is number {
144 return typeof value === 'number' && Number.isFinite(value);
145}
146
147function isRecord(value: unknown): value is Record<string, unknown> {
148 return typeof value === 'object' && value !== null && !Array.isArray(value);
149}
150hooks/kit/vocab.ts 27 lines1// Generated from src/formatting/vocab.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Canonical names for colors and text modifiers.
5 *
6 * Exported as `as const` tuples so callers can both type-check against the
7 * union AND iterate the values (e.g. to build a picker). Adding a new color
8 * here forces every code map (FG/BG in tags.ts) to also cover it.
9 */
10
11export const COLORS = [
12 'black',
13 'red',
14 'green',
15 'yellow',
16 'blue',
17 'magenta',
18 'cyan',
19 'white',
20 'gray',
21 'grey',
22] as const;
23export type ColorName = typeof COLORS[number];
24
25export const MODIFIERS = ['bold', 'dim', 'italic', 'underline'] as const;
26export type ModifierName = typeof MODIFIERS[number];
27hooks/kit/types.ts 43 lines1// Generated from src/widgets/types.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Widgets: small, typed pieces of state a plugin (or the model, through a
5 * plugin's tool) hands over to be drawn. A widget owns its data, never its
6 * placement: the host decides where the rows go.
7 *
8 * This folder is pure — no Node, no dependencies — because it is copied
9 * verbatim into the plugin-kit mod, whose environment has neither.
10 */
11
12import type { ColorName } from './vocab.ts';
13
14export const WIDGET_TYPE_NAMES = ['text', 'counter', 'meter', 'clock', 'list', 'tags'] as const;
15export type WidgetType = typeof WIDGET_TYPE_NAMES[number];
16
17/** A named color from the shared vocab, or a hex like `#c0392b` / `#c33`. */
18export type WidgetColor = ColorName | `#${string}`;
19
20/**
21 * Fields every widget may carry. `color` paints the main part of the row
22 * (never the note); `group` draws a dim heading above the first row of a run.
23 */
24export interface WidgetCommon {
25 note?: string;
26 color?: WidgetColor;
27 group?: string;
28}
29
30export interface TextWidget extends WidgetCommon { type: 'text'; value: string }
31export interface CounterWidget extends WidgetCommon { type: 'counter'; value: number }
32export interface MeterWidget extends WidgetCommon { type: 'meter'; value: number; max: number }
33export interface ClockWidget extends WidgetCommon { type: 'clock'; value: number; of: number }
34export interface ListWidget extends WidgetCommon { type: 'list'; value: string[] }
35export interface TagsWidget extends WidgetCommon { type: 'tags'; value: string[] }
36
37export type Widget = TextWidget | CounterWidget | MeterWidget | ClockWidget | ListWidget | TagsWidget;
38
39/** Widgets by name, in draw order (object key order). */
40export type Widgets = Record<string, Widget>;
41
42export const MAX_CLOCK_SEGMENTS = 12;
43hooks/kit/icons.ts 27 lines1// Generated from src/formatting/icons.ts by scripts/sync-plugin.mjs. Do not edit.
2
3/**
4 * Named icons for use in output strings.
5 *
6 * Drop them in with template literals — no parser, no tag grammar:
7 *
8 * builder.appendLine(`${ICONS.check} build passed`);
9 * builder.appendLine(`${ICONS.warn} ${count} files skipped`);
10 *
11 * To add an icon: put it in `ICONS` and it's instantly available.
12 * Typos are compile errors (`ICONS.chek` won't typecheck).
13 */
14
15export const ICONS = {
16 check: '✓',
17 cross: '✗',
18 warn: '⚠',
19 info: 'ℹ',
20 arrow: '▸',
21 bullet: '•',
22 dot: '·',
23 star: '★',
24} as const;
25
26export type IconName = keyof typeof ICONS;
27types/index.d.ts 92 lines1// The contract of `$.kit`, the noun plugin-kit adds to every mod's `$`.
2//
3// A plugin that lists "plugin-kit" under `dependencies` in its plugin.json
4// gets this file laid into its own .claude-plugin/types/ by the engine, so
5// `$.kit` is typed with nothing copied. Self-contained by the engine's rule:
6// no imports, every exported name led by `Kit`. It restates the widget types
7// of src/widgets; scripts/contract-check.ts fails the typecheck if they drift.
8
9export type KitColorName = 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'gray' | 'grey';
10export type KitWidgetColor = KitColorName | `#${string}`;
11
12export interface KitWidgetCommon {
13 note?: string;
14 color?: KitWidgetColor;
15 group?: string;
16}
17export interface KitTextWidget extends KitWidgetCommon { type: 'text'; value: string }
18export interface KitCounterWidget extends KitWidgetCommon { type: 'counter'; value: number }
19export interface KitMeterWidget extends KitWidgetCommon { type: 'meter'; value: number; max: number }
20export interface KitClockWidget extends KitWidgetCommon { type: 'clock'; value: number; of: number }
21export interface KitListWidget extends KitWidgetCommon { type: 'list'; value: string[] }
22export interface KitTagsWidget extends KitWidgetCommon { type: 'tags'; value: string[] }
23export type KitWidget = KitTextWidget | KitCounterWidget | KitMeterWidget | KitClockWidget | KitListWidget | KitTagsWidget;
24
25/** Widgets by name, in draw order. */
26export type KitWidgets = { [name: string]: KitWidget };
27
28/**
29 * A UI description: plain JSON shaped like a mod's element tree. Turn it into
30 * elements with `hydrate` (src/widgets/hydrate.ts, copied into your hooks).
31 */
32export type KitUiProp = string | number | boolean;
33export type KitUiNode = string | KitUiElement;
34export interface KitUiElement {
35 type: 'Box' | 'Text' | 'Button';
36 props?: { [name: string]: KitUiProp };
37 children?: KitUiNode[];
38}
39
40/** Which characters draw bars, segments, bullets and separators. */
41export type KitGlyphs = 'unicode' | 'ascii';
42
43export type KitParseResult = { ok: true; widget: KitWidget } | { ok: false; error: string };
44
45export type KitJson = string | number | boolean | null | KitJson[] | { [key: string]: KitJson };
46
47export interface KitRenderArgs {
48 /**
49 * Names this drawing, e.g. `${plugin}:${e.requestId}`. It keys the kit's
50 * view state (which lists are unfolded) and prefixes its Button keys.
51 */
52 id: string;
53 /**
54 * The widgets, in draw order. Unchecked input is fine: an invalid widget
55 * draws as text rather than failing the drawing (use `parse` to refuse).
56 */
57 widgets: { [name: string]: KitJson };
58 /**
59 * The three below override the user's plugin-kit settings for this
60 * drawing; leave them out to draw the way the user chose.
61 */
62 /** Cells in a meter's bar. The user's default, else 10. */
63 barWidth?: number;
64 /** Items a list shows before folding the rest behind a button. The user's default, else 5. */
65 listLimit?: number;
66 /** The user's default, else `unicode`. */
67 glyphs?: KitGlyphs;
68}
69
70export type Kit = {
71 /** The widgets as a UI description. Read inside `ui.render`, it redraws when the kit's view state changes. */
72 render(args: KitRenderArgs): Promise<KitUiElement>;
73 /** The widgets on one line of plain text, joined with ` · `: for a status line. */
74 line(args: { widgets: { [name: string]: KitJson } }): Promise<string>;
75 /** Validates one widget; every error names the widget and the fix. */
76 parse(args: { name: string; widget: KitJson }): Promise<KitParseResult>;
77 /** The catalog for a model: a markdown table, and a JSON Schema for one widget as a tool input. */
78 catalog(): Promise<{ table: string; schema: { [key: string]: KitJson } }>;
79};
80
81declare module 'claude-code' {
82 interface EngineInterface {
83 kit: Kit;
84 }
85 interface PluginState {
86 'plugin-kit': {
87 /** Per drawing id, the names of its lists drawn unfolded. */
88 expanded: { [id: string]: string[] };
89 };
90 }
91}
92