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

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:
| Tab | Shows |
|---|---|
| MCPs | Every 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 |
| Models | The 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.
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.
claude --plugin-dir /path/to/cc-palette
/palette — open the pane on the MCPs tab/palette models — open it on the Models tab1 / 2 — switch between MCPs and Modelsr — refresh the dataTab / arrows — move between controlsEsc — close the pane/model pickerThe pane reads configuration when it opens; press r after changing MCP servers or settings.
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.
The mod is a single hooks module (hooks/register.js):
session.start registers the /palette command and restores a stashed draft after an interrupted sessioncommand.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 itselfui.close restores the stashed draft when the pane closesturn.step observes the model and effort each request goes out with, for the Session and Effort rowsui.render, filtered to this pane, reads the configuration through the mods API and draws the tabsData sources:
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.$.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.claude plugin validate .
claude --plugin-dir .
With --plugin-dir, the hooks module reloads when you save it.
MIT
hooks/register.js 550 lines1/**
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