A side pane listing the session's agents, skills and commands; click one to put it in the prompt box.

A side pane listing this session's agents, skills and commands. Click one to put it in the prompt box, ready to finish and send.
Click to put it in the prompt ↻
Search: type to filter
▾ most used (2)
/think
Meta-cognitive reasoning …
@rust-engineer
Expert Rust developer …
▾ project (2)
@reviewer
Reviews diffs for …
/deploy
Deploy the current branch
▸ plugin:hookify (5)
claude plugin install palette@vampik-plugins
/palette shows or hides the pane, with the keyboard on the search field so you can type right away. It docks beside the transcript in the fullscreen layout (110+ columns), inline above the prompt otherwise.palette show line always sits above the prompt: show / hide toggles the pane (hotkey p once the band has the keys, ctrl+x tab or a click)./<name> (a slash command only runs from there), replacing a leading command and keeping the rest of your draft.@agent-<name> .✕ clears the search.↻ re-reads the lists (after /reload-plugins, say).Groups: most used on top (the 10 items used most, also still listed under their source), then project (.claude/ in the working directory), user, one per plugin, and mcp. Built-in commands and agents are left out.
Commands and skills come from $.command.list(); custom agents from the context breakdown of $.session.usage(). That listing has no agent descriptions: they fill in from agent.offer once the model has been offered the agents (after the first prompt). A user-source item is put under project when .claude/commands/<name>.md or .claude/skills/<name>/SKILL.md exists in the working directory.
Uses are counted from command.run, skill.prompt and agent.spawn (so a skill the model calls through the Skill tool, or an agent it spawns, counts too) and kept in the plugin's $.store, shared by every session. A typed skill raises both command.run and skill.prompt: a second use of one item within 5 seconds is not counted.
Needs function hooks (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1).
hooks/register.tsx 289 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { PaletteGroup, PaletteItem, Usage } from "../types";
5import {
6 buildGroups,
7 filterGroups,
8 fillFor,
9 label,
10 MOST_USED,
11 mostUsed,
12 projectPaths,
13 recordUse,
14} from "./core.ts";
15
16const PANE = "palette";
17const SEARCH = "palette-search";
18const SELF = "palette";
19
20const groups = atom(
21 { plugin: "palette", key: "groups" } as const,
22 [] as PaletteGroup[],
23);
24const pane = atom({ plugin: "palette", key: "pane" } as const, false);
25const folded = atom(
26 { plugin: "palette", key: "folded" } as const,
27 [] as string[],
28);
29const agentInfo = atom(
30 { plugin: "palette", key: "agentInfo" } as const,
31 {} as Record<string, string>,
32);
33const usage = atom({ plugin: "palette", key: "usage" } as const, {} as Usage);
34const USAGE = "usage";
35const query = atom({ plugin: "palette", key: "query" } as const, "");
36
37const stored = async ($: EngineInterface) =>
38 ((await $.store.get(USAGE)) as Usage | undefined) ?? {};
39
40/** Counts one use in the store (shared by every session) and in `usage`. */
41async function addUse($: EngineInterface, key: string): Promise<void> {
42 const was = await stored($);
43 const now = recordUse(was, key, await $.clock.now());
44 if (now !== was) await $.store.set(USAGE, now);
45 await update($, usage, () => now);
46}
47
48// one at a time, so parallel spawns don't overwrite each other's count
49let counting = Promise.resolve();
50function countUse($: EngineInterface, key: string): Promise<void> {
51 counting = counting.then(() => addUse($, key)).catch(() => {});
52 return counting;
53}
54
55/** Reads the session's commands, skills and custom agents into `groups`. */
56async function refresh($: EngineInterface): Promise<void> {
57 const commands = await $.command.list().catch(() => []);
58 const usage = await $.session
59 .usage({ breakdown: "summary" })
60 .catch(() => undefined);
61 const agents = usage?.context.breakdown?.agents ?? [];
62 const local = await Promise.all(
63 commands
64 .filter((c) => c.source === "user")
65 .map(async (c) => {
66 const hits = await Promise.all(
67 projectPaths(c.name).map((p) => $.fs.exists(p).catch(() => false)),
68 );
69 return hits.some(Boolean) ? c.name : undefined;
70 }),
71 );
72 const projectNames = new Set(local.filter((n) => n !== undefined));
73 await update($, groups, () =>
74 buildGroups(commands, agents, projectNames, SELF),
75 );
76}
77
78async function openPane($: EngineInterface) {
79 // focus is granted only over an empty prompt; the search field takes it (autoFocus)
80 const opened = await $.ui.open({ id: PANE, title: "Palette", focus: true });
81 await update($, pane, () => opened.isPlaced);
82 await refresh($);
83 return opened;
84}
85
86/** Closes the pane if the stored state says open, opens it if not. */
87async function togglePane($: EngineInterface) {
88 if (await read($, pane)) {
89 await $.ui.close({ id: PANE });
90 await update($, pane, () => false);
91 return undefined;
92 }
93 return openPane($);
94}
95
96async function clearSearch($: EngineInterface) {
97 await update($, query, () => "");
98 // the ✕ goes with the text: give the keys back to the field
99 await $.ui.focus({ requestId: PANE, key: SEARCH }).catch(() => {});
100}
101
102async function press($: EngineInterface, item: PaletteItem) {
103 const box = await $.prompt.read();
104 const filled = await $.prompt.fill(fillFor(item, box.text, box.cursor));
105 if (!filled.isFilled)
106 $.ui.toast(`palette: the prompt box did not take ${label(item)}`);
107}
108
109export const register: Register = (on) => {
110 on("session.start", async ($, e, next) => {
111 const r = await next(e);
112 await $.command.register({
113 name: "palette",
114 description:
115 "Show or hide the pane listing this session's agents, skills and commands",
116 });
117 // a reload keeps an open pane
118 const panes = await $.ui.panes().catch(() => []);
119 const isOpen = panes.some((p) => p.id === PANE && p.isPlaced);
120 await update($, pane, () => isOpen);
121 const kept = await stored($).catch(() => ({}));
122 await update($, usage, () => kept);
123 if (isOpen) await refresh($);
124 return r;
125 });
126
127 on("command.run", { command: "palette" }, async ($) => {
128 const opened = await togglePane($);
129 return {
130 text: !opened
131 ? "Palette closed."
132 : opened.isPlaced
133 ? "Palette opened."
134 : "Palette is waiting for a wider terminal.",
135 };
136 });
137
138 on("command.run", async ($, e, next) => {
139 await countUse($, `command:${e.command}`);
140 return next(e);
141 });
142
143 on("skill.prompt", async ($, e, next) => {
144 await countUse($, `command:${e.skill}`);
145 return next(e);
146 });
147
148 on("agent.spawn", async ($, e, next) => {
149 await countUse($, `agent:${e.subagentType}`);
150 return next(e);
151 });
152
153 // the context's agent listing has no descriptions; the model's offer does
154 on("agent.offer", async ($, e, next) => {
155 const r = await next(e);
156 if ((await read($, agentInfo))[e.agent] !== e.description)
157 await update($, agentInfo, (m) => ({ ...m, [e.agent]: e.description }));
158 return r;
159 });
160
161 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
162 const els = $.ui.resolve(e);
163 const { Box, Button, Text } = els;
164 const Input = "Input" in els ? els.Input : undefined;
165 const listed = await read($, groups);
166 const top = mostUsed(listed, await read($, usage));
167 const info = await read($, agentInfo);
168 const q = await read($, query);
169 const all = filterGroups(top ? [top, ...listed] : listed, q, info);
170 // while searching every group with a match shows open
171 const shut = new Set(q.trim() ? [] : await read($, folded));
172 const toggle = (id: string) =>
173 update($, folded, (f) =>
174 f.includes(id) ? f.filter((x) => x !== id) : [...f, id],
175 );
176
177 return (
178 <Box flexDirection="column" width={e.props.bodyColumns}>
179 <Box flexDirection="row" columnGap={1}>
180 <Text dimColor>Click to put it in the prompt</Text>
181 <Button
182 key="palette-refresh"
183 plain
184 dimColor
185 onPress={() => refresh($)}
186 >
187 ↻
188 </Button>
189 </Box>
190 {Input && (
191 <Box flexDirection="row" columnGap={1} marginTop={1}>
192 <Input
193 key={SEARCH}
194 label="Search: "
195 placeholder="type to filter"
196 value={q}
197 autoFocus
198 submitLabel="put first"
199 onInput={(v) => update($, query, () => v)}
200 onSubmit={() => {
201 const first = all[0]?.items[0];
202 if (first) void press($, first);
203 }}
204 />
205 {q !== "" && (
206 <Button
207 key="palette-search-clear"
208 plain
209 dimColor
210 onPress={() => clearSearch($)}
211 >
212 ✕
213 </Button>
214 )}
215 </Box>
216 )}
217 {all.length === 0 && (
218 <Text dimColor>
219 {q.trim()
220 ? `Nothing matches "${q.trim()}".`
221 : "No agents, skills or commands."}
222 </Text>
223 )}
224 {all.map((g) => (
225 <Box key={`section:${g.id}`} flexDirection="column" marginTop={1}>
226 <Button key={`group:${g.id}`} plain onPress={() => toggle(g.id)}>
227 {`${shut.has(g.id) ? "▸" : "▾"} ${g.title} (${g.items.length})`}
228 </Button>
229 {!shut.has(g.id) &&
230 g.items.map((item) => {
231 const id = `${g.id === MOST_USED ? "top:" : ""}${item.kind}:${item.name}`;
232 const about = item.description || info[item.name] || "";
233 return (
234 <Box
235 key={`row:${id}`}
236 flexDirection="column"
237 marginTop={1}
238 marginLeft={2}
239 >
240 <Button key={id} plain onPress={() => press($, item)}>
241 {label(item)}
242 </Button>
243 {about !== "" && (
244 <Box marginLeft={2}>
245 <Text dimColor wrap="truncate-end">
246 {about}
247 </Text>
248 </Box>
249 )}
250 </Box>
251 );
252 })}
253 </Box>
254 ))}
255 </Box>
256 );
257 });
258
259 // its own line under the other bands: a show/hide toggle
260 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
261 if (e.props.hasSurvey) return next(e);
262 const open = await read($, pane);
263 const below = await next(e);
264 const { Box, Button, Text } = $.ui.resolve(e);
265 return (
266 <Box flexDirection="column">
267 {below}
268 <Box flexDirection="row" columnGap={2} marginTop={1}>
269 <Text dimColor>palette</Text>
270 <Button
271 key="palette-toggle"
272 hotkey="p"
273 dimColor
274 onPress={() => togglePane($)}
275 >
276 {open ? "hide" : "show"}
277 </Button>
278 </Box>
279 </Box>
280 );
281 });
282
283 on("ui.close", { id: PANE }, async ($, e, next) => {
284 const r = await next(e);
285 await update($, pane, () => false);
286 return r;
287 });
288};
289hooks/core.ts 175 lines1/**
2 * core.ts — pure logic: the session's commands and agents grouped by where
3 * they come from, and what a press writes into the prompt box.
4 */
5
6import type { PaletteGroup, PaletteItem, Usage } from "../types";
7
8/** One slash command or skill as `$.command.list()` gives it. */
9export type RawCommand = {
10 name: string;
11 description: string;
12 source: "builtin" | "plugin" | "user" | "mcp";
13 plugin?: string;
14};
15
16/** One custom agent as the context breakdown lists it. */
17export type RawAgent = { agentType: string; source: string };
18
19const AGENT_GROUP: Record<string, string> = {
20 projectSettings: "project",
21 localSettings: "project",
22 userSettings: "user",
23};
24
25const pluginOf = (name: string) =>
26 name.includes(":") ? name.slice(0, name.indexOf(":")) : name;
27
28/** Where a user-source command or skill would live in the project's `.claude/`. */
29export function projectPaths(name: string): string[] {
30 const parts = name.split(":");
31 const paths = [`.claude/commands/${parts.join("/")}.md`];
32 if (parts.length === 1) paths.push(`.claude/skills/${name}/SKILL.md`);
33 return paths;
34}
35
36const rank = (id: string) =>
37 id === "project" ? 0 : id === "user" ? 1 : id.startsWith("plugin:") ? 2 : 3;
38
39/**
40 * Groups by source: project, user, each plugin, then anything else (mcp).
41 * Built-in commands and the palette's own entries are left out.
42 */
43export function buildGroups(
44 commands: RawCommand[],
45 agents: RawAgent[],
46 projectNames: ReadonlySet<string>,
47 self: string,
48): PaletteGroup[] {
49 const groups = new Map<string, PaletteItem[]>();
50 const add = (id: string, item: PaletteItem) => {
51 if (id === `plugin:${self}`) return;
52 groups.set(id, [...(groups.get(id) ?? []), item]);
53 };
54
55 for (const a of agents) {
56 const id =
57 a.source === "plugin"
58 ? `plugin:${pluginOf(a.agentType)}`
59 : (AGENT_GROUP[a.source] ?? a.source);
60 // the listing has no descriptions: agent.offer's fill them in when drawn
61 add(id, { kind: "agent", name: a.agentType, description: "" });
62 }
63 for (const c of commands) {
64 if (c.source === "builtin") continue;
65 const id =
66 c.source === "plugin"
67 ? `plugin:${c.plugin ?? pluginOf(c.name)}`
68 : c.source === "user"
69 ? projectNames.has(c.name)
70 ? "project"
71 : "user"
72 : c.source;
73 add(id, { kind: "command", name: c.name, description: c.description });
74 }
75
76 return [...groups]
77 .map(([id, items]) => ({
78 id,
79 title: id,
80 items: items.sort(
81 (a, b) =>
82 Number(a.kind === "command") - Number(b.kind === "command") ||
83 a.name.localeCompare(b.name),
84 ),
85 }))
86 .sort((a, b) => rank(a.id) - rank(b.id) || a.id.localeCompare(b.id));
87}
88
89/** Where an item's uses are kept: `<kind>:<name>`. */
90export const useKey = (item: Pick<PaletteItem, "kind" | "name">) =>
91 `${item.kind}:${item.name}`;
92
93/** A typed skill is reported twice (`command.run`, `skill.prompt`): once counts. */
94export const REPEAT_MS = 5_000;
95
96/** `usage` with one more use of `key` at `now`; unchanged within REPEAT_MS of the last. */
97export function recordUse(usage: Usage, key: string, now: number): Usage {
98 const was = usage[key];
99 if (was && now - was.last < REPEAT_MS) return usage;
100 return { ...usage, [key]: { count: (was?.count ?? 0) + 1, last: now } };
101}
102
103export const MOST_USED = "most used";
104
105/**
106 * The `n` most used items listed in `groups`, most first (ties: the latest
107 * use, then by name); undefined while none has a use.
108 */
109export function mostUsed(
110 groups: PaletteGroup[],
111 usage: Usage,
112 n = 10,
113): PaletteGroup | undefined {
114 const seen = new Map<string, PaletteItem>();
115 for (const item of groups.flatMap((g) => g.items))
116 if (usage[useKey(item)]) seen.set(useKey(item), item);
117 const items = [...seen.values()]
118 .sort((a, b) => {
119 const ua = usage[useKey(a)]!;
120 const ub = usage[useKey(b)]!;
121 return (
122 ub.count - ua.count || ub.last - ua.last || a.name.localeCompare(b.name)
123 );
124 })
125 .slice(0, n);
126 return items.length
127 ? { id: MOST_USED, title: MOST_USED, items }
128 : undefined;
129}
130
131/**
132 * The groups with only the items whose name or description holds every word
133 * of `query` (any case); groups left empty are dropped. A blank query keeps all.
134 */
135export function filterGroups(
136 groups: PaletteGroup[],
137 query: string,
138 info: Record<string, string> = {},
139): PaletteGroup[] {
140 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
141 if (words.length === 0) return groups;
142 const hit = (item: PaletteItem) => {
143 const text =
144 `${item.name} ${item.description || info[item.name] || ""}`.toLowerCase();
145 return words.every((w) => text.includes(w));
146 };
147 return groups
148 .map((g) => ({ ...g, items: g.items.filter(hit) }))
149 .filter((g) => g.items.length > 0);
150}
151
152/** What a row reads: `@<name>` for an agent, `/<name>` for a command or skill. */
153export const label = (item: PaletteItem) =>
154 `${item.kind === "agent" ? "@" : "/"}${item.name}`;
155
156/**
157 * What a press puts in the prompt box. A command or skill goes first (a slash
158 * command only runs from the start), replacing a leading one; an agent
159 * mention goes in at the cursor, spaced from the word before it.
160 */
161export function fillFor(
162 item: PaletteItem,
163 draft: string,
164 cursor: number,
165): { text: string; mode: "replace" | "insert" } {
166 if (item.kind === "command")
167 return {
168 text: `/${item.name} ${draft.replace(/^\/\S+\s*/, "")}`,
169 mode: "replace",
170 };
171 const before = draft.slice(0, cursor);
172 const gap = before === "" || /\s$/.test(before) ? "" : " ";
173 return { text: `${gap}@agent-${item.name} `, mode: "insert" };
174}
175types/index.d.ts 38 lines1/** One clickable row: an agent inserts `@agent-<name>`, a command or skill `/<name>`. */
2export type PaletteItem = {
3 kind: "agent" | "command";
4 name: string;
5 description: string;
6};
7
8/** Items of one source: `project`, `user`, `plugin:<name>` or `mcp`. */
9export type PaletteGroup = {
10 id: string;
11 title: string;
12 items: PaletteItem[];
13};
14
15/** How often an item was used, and when last (ms since epoch). */
16export type Use = { count: number; last: number };
17
18/** Uses by `<kind>:<name>` (`command:think`, `agent:rust-engineer`). */
19export type Usage = Record<string, Use>;
20
21declare module "claude-code" {
22 interface PluginState {
23 palette: {
24 groups: PaletteGroup[];
25 /** Whether the pane is open, as the last open or close left it. */
26 pane: boolean;
27 /** Group ids the person folded. */
28 folded: string[];
29 /** Agent descriptions by type, as `agent.offer` last carried them. */
30 agentInfo: Record<string, string>;
31 /** Uses across sessions, as `$.store` keeps them. */
32 usage: Usage;
33 /** What the search field holds; items are filtered by it. */
34 query: string;
35 };
36 }
37}
38