SLOPSHOPPER

Command Palette

A /palette pane that shows your MCP servers and models without leaving the prompt.

newpanecommandtoast
v0.10.0MITupdated 2026-10-13daniel-morales-dev/cc-palette
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-palette
│ ┃ Palette ✕ › fix the failing auth test and add an audit log call │ ┃ 1: MCPs 2: Models r: Refresh │ ┃ ⏺ Read(src/auth.ts) │ ┃ No MCP servers configured for this session. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /palette │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Palette
1: MCPs 2: Models r: Refresh No MCP servers configured for this session.
README

cc-palette

A command palette pane for Claude Code: browse your MCP servers and models without leaving the prompt.

Type /palette and a pane opens beside the transcript with two tabs:

TabShows
MCPsEvery MCP server available to the session — global servers from ~/.claude.json, project-scoped servers, and the project's .mcp.json — with transport and launch target
ModelsThe session's model and effort, one-press switches through the native /model and /effort commands (model options from /config, effort levels low → max), and a button that opens the /model picker

Built as a Claude Code mod, so the command registers with immediate: true: it works while Claude is working, like OpenCode's ctrl+p palette.

Install

From the marketplace (recommended)

claude plugin marketplace add daniel-morales-dev/cc-palette
claude plugin install cc-palette@cc-palette

Then run /palette in any session. If you installed from your shell with a session open, run /reload-plugins there.

Try it without installing

claude --plugin-dir /path/to/cc-palette

Requirements

  • Claude Code v2.1.287 or later (mods)
  • Mods are on by default; nothing else to enable

Usage

  • /palette — open the pane on the MCPs tab
  • /palette models — open it on the Models tab
  • Keys while the pane has focus:
  • 1 / 2 — switch between MCPs and Models
  • r — refresh the data
  • Tab / arrows — move between controls
  • Esc — close the pane
  • On the Models tab, press a model or an effort level to pin it (●) — keep moving, the pane stays — then press a pinned option again to apply everything to the session at once; Change model opens the native /model picker
  • With the Ctrl+P binding below, the shortcut opens the pane and closes it; a pick dismisses the pane by itself, so the next Ctrl+P always brings it back fresh, focused on the same tab

The pane reads configuration when it opens; press r after changing MCP servers or settings.

Open it from the keyboard (Ctrl+P / Cmd+P)

Mods can't bind global keys, but Claude Code's own keybindings can run a slash command. Add this to ~/.claude/keybindings.json (run /keybindings to create the file):

{
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "ctrl+p": "command:palette",
        "cmd+p": "command:palette"
      }
    },
    {
      "context": "Pane",
      "bindings": {
        "space": "abovePrompt:press"
      }
    }
  ]
}

Now Ctrl+P opens the palette — even while you are typing — and pressing it again closes it. A pick (a model or an effort) dismisses the pane on its own and reports under the prompt, so the next Ctrl+P opens it fresh and focused, on the same tab. The second binding makes Space press the focused option inside the pane, like Enter — a first press pins the option (●), pressing it again applies it to the session. The file is watched, so changes apply without restarting Claude Code. (cmd+p needs a terminal that reports the Super key, such as one with the Kitty keyboard protocol.)

A draft in the composer is stashed while the pane is open and put back when it closes, so the pane takes the keyboard right away.

How it works

The mod is a single hooks module (hooks/register.js):

  • session.start registers the /palette command and restores a stashed draft after an interrupted session
  • command.run stashes any composer draft, opens the pane ($.ui.open) with the keyboard, and closes it on the next press — a pane that lost the keyboard cannot take it back (verified against the engine), so a fresh open is the way back, and applying the pins dismisses the pane itself
  • ui.close restores the stashed draft when the pane closes
  • turn.step observes the model and effort each request goes out with, for the Session and Effort rows
  • ui.render, filtered to this pane, reads the configuration through the mods API and draws the tabs

Data sources:

  • MCP servers: mcpServers in ~/.claude.json, projects[<cwd>].mcpServers in the same file, and mcpServers in <project>/.mcp.json. Later sources override earlier ones, so project config wins over global.
  • Models: the session model ($.session.model()), model from ~/.claude/settings.json or the project's .claude/settings.json, and the ANTHROPIC_MODEL / ANTHROPIC_SMALL_FAST_MODEL environment variables. The model options come from the /config row ($.config.list()); a press runs the native /model <alias> or /effort <level> ($.command.run) — the same switch the slash commands make, so the status line at the bottom reflects it. A pinned option applies with the native command when pressed again; the pane then dismisses and leaves the result as a toast under the prompt. Ctrl+P opens it again, focused on the same tab. Effort needs a model that supports it (Opus, Sonnet); on a model without it the pane says so. Change model opens the picker for versions and the effort slider.

Development

claude plugin validate .
claude --plugin-dir .

With --plugin-dir, the hooks module reloads when you save it.

License

MIT

Source 1 files
hooks/register.js 550 lines
1/**
2 * cc-palette — a command palette pane for Claude Code.
3 *
4 * `/palette` opens a pane with two tabs:
5 *   MCPs    — the MCP servers configured for this session (global and project)
6 *   Models  — the session model and effort, one-press switches through the
7 *             native /model and /effort commands (the model list from
8 *             /config), and a button that opens the /model picker
9 *
10 * The command registers with `immediate: true`, so it runs while Claude is
11 * working. Bind Ctrl+P (or Cmd+P) to `command:palette` in
12 * ~/.claude/keybindings.json to open it from the keyboard — see the README.
13 *
14 * When the composer holds a draft, the draft is stashed while the pane is open
15 * and restored when it closes, so the pane takes the keyboard right away.
16 */
17
18const PANE_ID = 'cc-palette'
19const STASH_KEY = 'stashed-draft'
20
21let tab = 'mcps'
22let data = null
23let notice = ''
24let lastStep = null
25let pendingStatus = ''
26let pinnedModel = ''
27let pinnedEffort = ''
28
29function asString(value) {
30  return typeof value === 'string' ? value : ''
31}
32
33async function readJson($, path) {
34  try {
35    if (!(await $.fs.exists(path))) return null
36    return JSON.parse(await $.fs.read(path))
37  } catch {
38    return null
39  }
40}
41
42function describeServer(config) {
43  const cfg = config && typeof config === 'object' ? config : {}
44  const kind = asString(cfg.type) || (asString(cfg.url) ? 'http' : 'stdio')
45  const target =
46    asString(cfg.url) ||
47    [asString(cfg.command), ...(Array.isArray(cfg.args) ? cfg.args : [])]
48      .filter((part) => typeof part === 'string' && part.length > 0)
49      .join(' ')
50  return { kind, target }
51}
52
53// "sonnet[1m]" reads as "Sonnet (1M)"; unknown values stay as they are.
54function prettyValue(value) {
55  const names = {
56    best: 'Best',
57    default: 'Default',
58    fable: 'Fable',
59    haiku: 'Haiku',
60    high: 'High',
61    low: 'Low',
62    max: 'Max',
63    medium: 'Medium',
64    none: 'None',
65    opus: 'Opus',
66    opusplan: 'Opus plan',
67    sonnet: 'Sonnet',
68  }
69  const plain = names[value]
70  if (plain) return plain
71  const oneM = /^([a-z]+)\[1m\]$/.exec(value)
72  if (oneM && names[oneM[1]]) return names[oneM[1]] + ' (1M)'
73  return value
74}
75
76// "claude-opus-5-5" reads as "Opus 5.5".
77function prettyModel(id) {
78  const match = /^claude-([a-z]+)-(\d+)-(\d+)$/.exec(asString(id))
79  if (!match) return asString(id)
80  const family = match[1].charAt(0).toUpperCase() + match[1].slice(1)
81  return family + ' ' + match[2] + '.' + match[3]
82}
83
84const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
85
86async function collect($) {
87  const home = asString(await $.env.get('HOME'))
88  const cwd = await $.session.cwd()
89  const mcps = []
90  const indexByName = {}
91
92  // Later sources override earlier ones: project config wins over global.
93  const add = (name, config, scope) => {
94    const { kind, target } = describeServer(config)
95    const entry = { name, scope, kind, target }
96    const i = indexByName[name]
97    if (i === undefined) {
98      indexByName[name] = mcps.length
99      mcps.push(entry)
100    } else {
101      mcps[i] = entry
102    }
103  }
104
105  const claudeJson = await readJson($, home + '/.claude.json')
106  const globalServers =
107    claudeJson && claudeJson.mcpServers && typeof claudeJson.mcpServers === 'object'
108      ? claudeJson.mcpServers
109      : {}
110  for (const [name, config] of Object.entries(globalServers)) add(name, config, 'global')
111
112  const projects =
113    claudeJson && claudeJson.projects && typeof claudeJson.projects === 'object'
114      ? claudeJson.projects
115      : {}
116  const projectEntry = projects[cwd]
117  const projectServers =
118    projectEntry && projectEntry.mcpServers && typeof projectEntry.mcpServers === 'object'
119      ? projectEntry.mcpServers
120      : {}
121  for (const [name, config] of Object.entries(projectServers)) add(name, config, 'project')
122
123  const localMcp = await readJson($, cwd + '/.mcp.json')
124  const localServers =
125    localMcp && localMcp.mcpServers && typeof localMcp.mcpServers === 'object'
126      ? localMcp.mcpServers
127      : {}
128  for (const [name, config] of Object.entries(localServers)) {
129    add(name, config, 'project (.mcp.json)')
130  }
131
132  const settings = await readJson($, home + '/.claude/settings.json')
133  const projectSettings = await readJson($, cwd + '/.claude/settings.json')
134  const models = {
135    session: await $.session.model(),
136    configured:
137      asString(projectSettings && projectSettings.model) ||
138      asString(settings && settings.model),
139    env: asString(await $.env.get('ANTHROPIC_MODEL')),
140    smallFast: asString(await $.env.get('ANTHROPIC_SMALL_FAST_MODEL')),
141  }
142
143  // `/config` rows about the model. A choice row is switchable from the pane;
144  // the rows a session actually has are the engine's to say, so read live.
145  let configs = []
146  try {
147    const rows = await $.config.list()
148    configs = rows
149      .filter((row) => /model|effort/i.test(row.key) || /model|effort/i.test(row.label))
150      .map((row) => ({
151        key: row.key,
152        label: row.label,
153        kind: row.kind,
154        value: typeof row.value === 'string' ? row.value : String(row.value),
155        options: Array.isArray(row.options) ? row.options.slice() : [],
156        isLocked: row.isLocked === true,
157      }))
158  } catch {}
159
160  return { mcps, models, configs }
161}
162
163async function stashDraft($) {
164  const box = await $.prompt.read()
165  if (!box.text) return { stashed: false, hint: false }
166  await $.store.set(STASH_KEY, box.text)
167  const cleared = await $.prompt.fill({ text: '', mode: 'replace' })
168  if (!cleared.isFilled) {
169    await $.store.delete(STASH_KEY)
170    return { stashed: false, hint: true }
171  }
172  return { stashed: true, hint: false }
173}
174
175async function restoreDraft($) {
176  const stash = await $.store.get(STASH_KEY)
177  if (typeof stash !== 'string' || stash === '') return
178  const box = await $.prompt.read()
179  const text = box.text ? stash + '\n' + box.text : stash
180  const filled = await $.prompt.fill({ text, mode: 'replace' })
181  if (filled.isFilled) await $.store.delete(STASH_KEY)
182}
183
184// The keyboard cannot be taken back programmatically once the pane loses it
185// (verified live: re-opens, waits and fresh ids are all refused), so a pick
186// dismisses the pane: the next Ctrl+P opens it fresh, and a fresh open takes
187// the keyboard every time.
188function closePane($) {
189  try {
190    $.ui.close({ id: PANE_ID })
191  } catch {}
192}
193
194// After the pane is gone, a toast is the message that shows: `$.ui.status`
195// is dropped from a press or render context, a toast is not.
196function flushStatusSoon($) {
197  try {
198    if (pendingStatus) {
199      $.ui.toast(pendingStatus, { timeoutMs: 6000 })
200      pendingStatus = ''
201    }
202  } catch {}
203}
204
205function mcpRows(Box, Text, mcps) {
206  if (mcps.length === 0) {
207    return [Text({ dimColor: true, children: ['No MCP servers configured for this session.'] })]
208  }
209  return mcps.map((mcp) =>
210    Box({
211      key: 'mcp-' + mcp.name,
212      flexDirection: 'column',
213      children: [
214        Box({
215          flexDirection: 'row',
216          columnGap: 1,
217          children: [
218            Text({ color: 'green', children: ['●'] }),
219            Text({ bold: true, children: [mcp.name] }),
220            Text({ dimColor: true, children: [mcp.kind + ' · ' + mcp.scope] }),
221          ],
222        }),
223        ...(mcp.target
224          ? [Text({ dimColor: true, wrap: 'truncate-end', children: ['  ' + mcp.target] })]
225          : []),
226      ],
227    }),
228  )
229}
230
231function modelRows(Box, Text, Button, models, configs, onPickModel, onPickEffort, onOpenPicker) {
232  const row = (key, label, value) =>
233    Box({
234      key: 'model-' + key,
235      flexDirection: 'row',
236      columnGap: 1,
237      children: [Text({ bold: true, children: [label] }), Text({ children: [value] })],
238    })
239
240  const active = lastStep && lastStep.model ? lastStep.model : models.session
241  const rows = [row('session', 'Session', prettyModel(active) || 'unknown')]
242  if (lastStep && lastStep.effort !== undefined && lastStep.effort !== null) {
243    rows.push(row('effort', 'Effort', prettyValue(String(lastStep.effort))))
244  }
245  if (models.configured) rows.push(row('default', 'Default (settings)', models.configured))
246  if (models.env) rows.push(row('env', 'ANTHROPIC_MODEL', models.env))
247  if (models.smallFast) rows.push(row('small', 'Small/fast', models.smallFast))
248  rows.push(Text({ children: [' '] }))
249
250  // The /config row names the models this build offers; a press switches the
251  // session through the native /model command, versions and all.
252  const modelRow =
253    configs.find((config) => /^model$/i.test(config.label)) ||
254    configs.find((config) => /^model$/i.test(config.key)) ||
255    configs.find((config) => config.kind === 'choice' && config.options.length > 0 && !config.isLocked)
256
257  for (const config of configs) {
258    if (config === modelRow) continue
259    rows.push(
260      row('cfg-' + config.key, config.label, config.value + (config.isLocked ? ' (locked)' : '')),
261    )
262  }
263
264  if (modelRow) {
265    rows.push(Text({ bold: true, children: [modelRow.label] }))
266    for (const option of modelRow.options) {
267      rows.push(
268        Button({
269          key: 'pick-model-' + option,
270          label:
271            (option === pinnedModel ? '● ' : option === modelRow.value ? '✓ ' : '  ') +
272            prettyValue(option),
273          plain: true,
274          dimColor: option !== modelRow.value && option !== pinnedModel,
275          onPress: () => onPickModel(option),
276        }),
277      )
278    }
279    rows.push(Text({ children: [' '] }))
280  }
281
282  rows.push(Text({ bold: true, children: ['Effort'] }))
283  for (const level of EFFORTS) {
284    rows.push(
285      Button({
286        key: 'pick-effort-' + level,
287        label: (level === pinnedEffort ? '● ' : '  ') + prettyValue(level),
288        plain: true,
289        dimColor: level !== pinnedEffort && !(lastStep && lastStep.effort === level),
290        onPress: () => onPickEffort(level),
291      }),
292    )
293  }
294  rows.push(Text({ children: [' '] }))
295
296  if (pinnedModel || pinnedEffort) {
297    const pinned = [pinnedModel && prettyValue(pinnedModel), pinnedEffort && prettyValue(pinnedEffort)]
298      .filter(Boolean)
299      .join(' · ')
300    rows.push(
301      Text({
302        dimColor: true,
303        children: ['● pinned (' + pinned + ') — press a pinned option again to apply it to the session.'],
304      }),
305    )
306    rows.push(Text({ children: [' '] }))
307  }
308
309  rows.push(
310    Button({
311      key: 'open-model-picker',
312      label: 'Change model (opens /model)',
313      onPress: () => onOpenPicker(),
314    }),
315  )
316  return rows
317}
318
319export function register(on) {
320  on('session.start', async ($, e, next) => {
321    try {
322      await $.command.register({
323        name: 'palette',
324        description: 'Open the palette pane: MCP servers and models',
325        argumentHint: '[mcps|models]',
326        immediate: true,
327      })
328    } catch {
329      // A built-in or another plugin already owns /palette; everything else still loads.
330    }
331    // A new session starts with fresh data: servers and models may differ.
332    data = null
333    lastStep = null
334    pinnedModel = ''
335    pinnedEffort = ''
336    // A draft left stashed by an interrupted session comes back here.
337    try {
338      await restoreDraft($)
339    } catch {}
340    return next(e)
341  })
342
343  on('command.run', { command: 'palette' }, async ($, e) => {
344    const arg = asString(e.args).trim().toLowerCase()
345    if (arg === 'mcps' || arg === 'models') tab = arg
346
347    const panes = await $.ui.panes()
348    const mine = panes.find((pane) => pane.id === PANE_ID)
349    if (mine) {
350      // Open: the shortcut closes it. An unfocused pane can only come back
351      // focused through a fresh open, so closing is also the way back.
352      closePane($)
353      if (!mine.isFocused) {
354        pendingStatus = 'Palette closed — Ctrl+P reopens it focused.'
355        flushStatusSoon($)
356      }
357      return {}
358    }
359
360    const stash = await stashDraft($)
361    notice = stash.hint
362      ? 'Composer has text — press Ctrl+X Tab to give this pane the keyboard.'
363      : stash.stashed
364        ? 'Draft stashed — it returns when the palette closes.'
365        : ''
366    data = null
367    await $.ui.open({ id: PANE_ID, title: 'Palette', focus: true, closeOnEscape: true })
368    return {}
369  })
370
371  on('ui.close', async ($, e, next) => {
372    if (e.id === PANE_ID) {
373      notice = ''
374      try {
375        await restoreDraft($)
376      } catch {}
377    }
378    return next(e)
379  })
380
381  // The Session and Effort rows read the last request's own values: the hook
382  // only observes. Main loop only — a subagent keeps the model its agent file
383  // names.
384  on('turn.step', async function* ($, e, next) {
385    try {
386      if (!e.agentId) lastStep = { model: e.model, effort: e.effort }
387    } catch {}
388    yield* next(e)
389  })
390
391  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
392    if (e.requestId !== PANE_ID) return next(e)
393    const { Box, Text, Button } = $.ui.resolve(e)
394    const redraw = () => $.ui.invalidate('ui.render')
395
396    if (!data) {
397      try {
398        data = await collect($)
399      } catch (error) {
400        data = {
401          mcps: [],
402          models: {},
403          configs: [],
404          error: error && error.message ? error.message : String(error),
405        }
406      }
407    }
408
409    // Pressing an option pins it and keeps the keyboard; pressing the pinned
410    // option again applies every pin to the session (native commands) and
411    // dismisses the pane.
412    const commitPicks = async () => {
413      const picks = []
414      if (pinnedModel)
415        picks.push({ command: 'model', args: pinnedModel, label: prettyValue(pinnedModel) })
416      if (pinnedEffort)
417        picks.push({ command: 'effort', args: pinnedEffort, label: prettyValue(pinnedEffort) })
418      pinnedModel = ''
419      pinnedEffort = ''
420      data = null
421      const applied = []
422      let failure = ''
423      for (const pick of picks) {
424        try {
425          await $.command.run({ command: pick.command, args: pick.args })
426          applied.push(pick.label)
427        } catch (error) {
428          failure =
429            'Could not run /' +
430            pick.command +
431            ' ' +
432            pick.args +
433            ': ' +
434            (error && error.message ? error.message : String(error))
435          break
436        }
437      }
438      closePane($)
439      pendingStatus = failure || 'Session set to ' + applied.join(' · ') + '.'
440      flushStatusSoon($)
441    }
442
443    const pickModel = (value) => {
444      if (value === pinnedModel) {
445        commitPicks()
446        return
447      }
448      pinnedModel = value
449      redraw()
450    }
451    const pickEffort = (value) => {
452      if (value === pinnedEffort) {
453        commitPicks()
454        return
455      }
456      pinnedEffort = value
457      redraw()
458    }
459
460    const openModelPicker = async () => {
461      try {
462        await $.command.run({ command: 'model' })
463      } catch (error) {
464        // Direct run refused: put /model in the composer so Enter runs it.
465        try {
466          await $.prompt.fill({ text: '/model', mode: 'replace' })
467          pendingStatus = 'Could not run /model directly — it is in your prompt: press Enter.'
468          closePane($)
469          flushStatusSoon($)
470          return
471        } catch {
472          pendingStatus =
473            'Could not open the model picker: ' +
474            (error && error.message ? error.message : String(error))
475          closePane($)
476          flushStatusSoon($)
477          return
478        }
479      }
480      data = null
481      pendingStatus = 'Model picker open — pick a model, then Ctrl+P reopens the palette.'
482      closePane($)
483      flushStatusSoon($)
484    }
485
486    const tabButton = (name, label, hotkey) =>
487      Button({
488        key: 'tab-' + name,
489        label,
490        hotkey,
491        plain: true,
492        dimColor: tab !== name,
493        onPress: () => {
494          tab = name
495          redraw()
496        },
497      })
498
499    const header = Box({
500      flexDirection: 'row',
501      columnGap: 3,
502      children: [
503        tabButton('mcps', 'MCPs', '1'),
504        tabButton('models', 'Models', '2'),
505        Button({
506          key: 'refresh',
507          label: 'Refresh',
508          hotkey: 'r',
509          plain: true,
510          dimColor: true,
511          onPress: () => {
512            data = null
513            redraw()
514          },
515        }),
516      ],
517    })
518
519    const body = data.error
520      ? [
521          Text({ children: ['Could not read the configuration: ' + data.error] }),
522          Text({ dimColor: true, children: ['Press r to retry.'] }),
523        ]
524      : tab === 'mcps'
525        ? mcpRows(Box, Text, data.mcps)
526        : modelRows(
527            Box,
528            Text,
529            Button,
530            data.models,
531            data.configs,
532            pickModel,
533            pickEffort,
534            openModelPicker,
535          )
536
537    return Box({
538      flexDirection: 'column',
539      children: [
540        header,
541        Text({ children: [' '] }),
542        ...body,
543        ...(notice
544          ? [Text({ children: [' '] }), Text({ dimColor: true, children: [notice] })]
545          : []),
546      ],
547    })
548  })
549}
550