SLOPSHOPPER

palette

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

newpanebandcommandtoastprompt
v0.4.0Apache-2.0updated 2026-10-06vampik33/claude-plugins/plugins/palette
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · palette
│ ┃ Palette ✕ › fix the failing auth test and add an audit log call │ ┃ Click to put it in the prompt ↻ │ ┃ ⏺ Read(src/auth.ts) │ ┃ Search: : type to filter ⏎ put first ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ▾ undefined (1) ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ /palette ⎿ 3 pass, 1 fail │ ┃ Show or hide the pane listing this sessi │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /palette │ ⎿ palette: Palette opened. │ │ ⟨Claude Code's own drawing⟩ palette [ hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ palette [ hide ]
Pane · Palette
Click to put it in the prompt ↻ Search: : type to filter ⏎ put first ▾ undefined (1) /palette Show or hide the pane listing this session's agents…
README

palette

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)

Installation

claude plugin install palette@vampik-plugins

Behaviour

  • /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.
  • A 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).
  • A command or skill goes to the start of the prompt as /<name> (a slash command only runs from there), replacing a leading command and keeping the rest of your draft.
  • An agent goes in at the cursor as @agent-<name> .
  • Nothing is sent: you finish the prompt and press Enter.
  • Type in the search field (click it, or focus the pane) to filter: an item stays when its name or description holds every word you typed, any case. Groups with no match hide, folded ones with a match open. Enter puts the first match in the prompt; ✕ clears the search.
  • Click a group header to fold it; ↻ 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.

How It Works

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).

Source 3 files
hooks/register.tsx 289 lines
1import { 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};
289
hooks/core.ts 175 lines
1/**
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}
175
types/index.d.ts 38 lines
1/** 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