One band above the prompt: session, git, context and plan-limit meters, cost, and your mods, model and effort controls.

Mods for Claude Code, installable on any machine (Windows, macOS, Linux) with no paths to configure.
command-deck is the main mod: one band above the prompt that shows your session at a glance and gives you buttons for the other mods. The other four are separate mods you add only if you want them. Each one adds its own slash command, and the deck shows a button for it only when it is installed.
| Mod | What it does | Command |
|---|---|---|
| command-deck | The band above the prompt. | /deck |
| github-issues | A pane listing the repo's open GitHub issues. | /issues |
| prompt-upgrade | Turns a rough prompt into a better one before it is sent. | /better |
| auto-model | Picks the right model for each prompt you type. | /automodel |
| dev-server | Starts, stops and restarts the project's dev server. | /dev |
In Claude Code, add the marketplace once:
/plugin marketplace add clafronteaccnts/commanddeck
Then install the deck:
/plugin install command-deck@commanddeck
Restart Claude Code. The deck works on its own, with its meters and the model and effort selectors.
Install any of these the same way, then restart Claude Code. Each one is optional.
/plugin install github-issues@commanddeck
/plugin install prompt-upgrade@commanddeck
/plugin install auto-model@commanddeck
/plugin install dev-server@commanddeck
One band above the prompt, with:
package.json version, the git branch, how many files changed, and commits ahead or behind.dev :9002 up or down, shown when the project's dev script names a port (for example next dev -p 9002).Run /deck to collapse the band to a single line, and again to expand it.
/issues opens a pane with the repo's open GitHub issues. Click one to open it in the browser, or press Ask to put "Work on issue #N" in the prompt. Needs the GitHub CLI (see Requirements).
/better <prompt> turns a rough prompt into a better one: it asks a few quick questions, rewrites the prompt and shows a side-by-side review before anything is sent.
/better on and /better off turn the automatic mode on and off./better history shows earlier upgrades.The deck's Prompt Upgrade button arms it for your next prompt.
/automodel on picks the right model for each prompt you type, quietly. /automodel off returns to the model you started on, and /automodel status shows the current state. The deck's Auto Model button toggles it, and the model selector gains an Auto choice.
/dev start, /dev stop, /dev restart and /dev status run the project's package.json dev script (npm run dev) and show its output in a pane. Plain /dev toggles it and opens the pane. Stopping also ends whatever is still listening on the dev port. It only stops a server it started itself; one started in a terminal has to be stopped there. The deck's Start dev and Stop dev button does the same.
/plugin marketplace update commanddeck
Restart Claude Code afterwards. Updates only arrive when a mod's version number changes.
/plugin uninstall command-deck@commanddeck
Use the same form for the other mods, for example github-issues@commanddeck.
git on your PATH for the git meter (it shows nothing in folders that aren't repos), and curl for the dev-server status (shown when the project's dev script names a port).gh), signed in with gh auth login.npm on your PATH, and a dev script in the project's package.json. To stop the server it uses netstat and taskkill on Windows, or lsof and kill on macOS and Linux.hooks/register.tsx 427 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import type { Limit, Snapshot } from '../types'
5
6const snap = atom({ plugin: 'command-deck', key: 'snap' } as const, null)
7const isCollapsed = atom({ plugin: 'command-deck', key: 'isCollapsed' } as const, false)
8const model = atom({ plugin: 'command-deck', key: 'model' } as const, 'default')
9const effort = atom({ plugin: 'command-deck', key: 'effort' } as const, 'default')
10const warned = atom({ plugin: 'command-deck', key: 'warned' } as const, {})
11
12// Owned by the auto-model and prompt-upgrade mods; read here only to show their state.
13const autoModelIsOn = atom({ plugin: 'auto-model', key: 'isOn' } as const, false)
14const upgradeIsArmed = atom({ plugin: 'prompt-upgrade', key: 'isArmed' } as const, false)
15
16const GOLD = '#F2C46D'
17const HAIR = '#262B33'
18const MUTED = '#7D8590'
19const TEXT = '#E6EDF3'
20const GREEN = '#3DD68C'
21const AMBER = '#E3B341'
22const RED = '#F47067'
23const DARK = '#0D1117'
24const SLATE = '#262B33'
25
26const TITLE = 'COMMAND DECK'
27const REFRESH_MS = 60_000
28
29const MODELS = [
30 { value: 'default', label: 'Session default' },
31 { value: 'claude-haiku-5-5', label: 'Haiku 5.5' },
32 { value: 'claude-sonnet-5-5', label: 'Sonnet 5.5' },
33 { value: 'claude-opus-5-5', label: 'Opus 5.5' },
34 { value: 'claude-fable-5-1', label: 'Fable 5.1' },
35]
36const EFFORTS = [
37 { value: 'default', label: 'Default' },
38 { value: 'low', label: 'Low' },
39 { value: 'medium', label: 'Medium' },
40 { value: 'high', label: 'High' },
41 { value: 'xhigh', label: 'XHigh' },
42 { value: 'max', label: 'Max' },
43]
44
45export function parseGit(stdout: string): { branch: string | null; changed: number; ahead: number; behind: number } {
46 let branch: string | null = null
47 let ahead = 0
48 let behind = 0
49 let changed = 0
50 for (const line of stdout.split('\n')) {
51 if (line.startsWith('# branch.head ')) {
52 const head = line.slice('# branch.head '.length).trim()
53 branch = head === '(detached)' ? 'detached' : head
54 } else if (line.startsWith('# branch.ab ')) {
55 const m = /\+(\d+) -(\d+)/.exec(line)
56 if (m) {
57 ahead = Number(m[1])
58 behind = Number(m[2])
59 }
60 } else if (line.trim() !== '' && !line.startsWith('#')) {
61 changed += 1
62 }
63 }
64 return { branch, changed, ahead, behind }
65}
66
67export function bar(percent: number, width: number): { on: string; off: string } {
68 const cells = Math.max(4, width)
69 const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * cells)
70 return { on: '▰'.repeat(filled), off: '▱'.repeat(cells - filled) }
71}
72
73export function tone(percent: number): string {
74 return percent >= 95 ? RED : percent >= 80 ? AMBER : GREEN
75}
76
77export function span(ms: number): string {
78 const minutes = Math.max(0, Math.floor(ms / 60_000))
79 if (minutes < 1) return '<1m'
80 if (minutes < 60) return `${minutes}m`
81 const hours = Math.floor(minutes / 60)
82 if (hours < 24) return `${hours}h ${minutes % 60}m`
83 return `${Math.floor(hours / 24)}d ${hours % 24}h`
84}
85
86export function tokens(n: number): string {
87 if (n >= 1_000_000) return `${Math.round(n / 100_000) / 10}M`.replace('.0M', 'M')
88 if (n >= 1000) return `${Math.round(n / 1000)}k`
89 return String(n)
90}
91
92export function limitLabel(kind: string): string {
93 if (kind === 'five_hour') return '5-HOUR'
94 if (kind === 'seven_day') return 'WEEKLY'
95 return kind.replace(/_/g, ' ').toUpperCase().slice(0, 10)
96}
97
98// The port a project's `dev` script serves on: `-p 9002`, `--port=9002`, `PORT=9002` and the like.
99export function parsePort(script: unknown): number | null {
100 if (typeof script !== 'string') return null
101 const m = /(?:--port[= ]|-p[= ]?|\bPORT=)(\d{2,5})\b/.exec(script)
102 const port = m ? Number(m[1]) : NaN
103 return port > 0 && port < 65536 ? port : null
104}
105
106function modelLabel(id: string): string {
107 const m = /(haiku|sonnet|opus|fable)-(\d+)-(\d+)/i.exec(id)
108 if (!m) return id.toUpperCase()
109 return `${m[1].toUpperCase()} ${m[2]}.${m[3]}`
110}
111
112function fit(text: string, columns: number): string {
113 return text.length <= columns ? text : `${text.slice(0, Math.max(0, columns - 1))}…`
114}
115
116let isRefreshing = false
117
118async function refresh($: Engine): Promise<void> {
119 if (isRefreshing) return
120 isRefreshing = true
121 try {
122 const now = await $.clock.now()
123 const cwd = await $.session.cwd()
124 const usage = await $.session.usage()
125 let git = { branch: null as string | null, changed: null as number | null, ahead: 0, behind: 0 }
126 try {
127 const r = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd, timeoutMs: 5000 })
128 if (r.exitCode === 0) git = parseGit(r.stdout)
129 } catch {
130 // not a git folder, or git is missing
131 }
132 let commands: string[] = []
133 try {
134 commands = (await $.command.list()).map(c => c.name)
135 } catch {
136 // show no mod chips until the list is available
137 }
138 let isGitHub = false
139 if (git.branch !== null) {
140 try {
141 const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd, timeoutMs: 5000 })
142 isGitHub = r.exitCode === 0 && /github\.com[:/]/i.test(r.stdout)
143 } catch {
144 // no origin remote
145 }
146 }
147 let version: string | null = null
148 let devPort: number | null = null
149 try {
150 const pkg = JSON.parse(await $.fs.read(`${cwd.replace(/[\\/]+$/, '')}/package.json`))
151 if (typeof pkg.version === 'string') version = pkg.version
152 devPort = parsePort(pkg.scripts?.dev)
153 } catch {
154 // no package.json, or it is not valid JSON
155 }
156 let isDevUp: boolean | null = null
157 if (devPort !== null) {
158 try {
159 const r = await $.process.run(['curl', '-s', '-I', '-m', '2', '-w', '\n%{http_code}', `http://localhost:${devPort}`], { cwd, timeoutMs: 5000 })
160 isDevUp = r.stdout.trim().split('\n').pop()?.trim() !== '000' && r.exitCode === 0
161 } catch {
162 isDevUp = false
163 }
164 }
165 const limits: Limit[] = usage.rateLimits.map(l => ({
166 kind: l.kind,
167 percent: l.percentUsed,
168 resetsAt: l.resetsAt ? Date.parse(l.resetsAt) : null,
169 }))
170 const next: Snapshot = {
171 now,
172 folder: cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? cwd,
173 version,
174 commands,
175 isGitHub,
176 devPort,
177 isDevUp,
178 branch: git.branch,
179 changed: git.changed,
180 ahead: git.ahead,
181 behind: git.behind,
182 startedAt: usage.startedAt,
183 prompts: await $.session.turns(),
184 costUsd: usage.cost?.usd ?? null,
185 contextPercent: usage.context.percent ?? null,
186 contextTokens: usage.context.tokens ?? null,
187 contextWindow: usage.context.window,
188 limits,
189 model: await $.session.model(),
190 }
191 await update($, snap, () => next)
192 await warn($, limits)
193 } catch {
194 // keep the last snapshot
195 } finally {
196 isRefreshing = false
197 }
198}
199
200// One quiet toast per limit window at 80% and again at 95%.
201async function warn($: Engine, limits: Limit[]): Promise<void> {
202 const seen = await read($, warned)
203 const marks: Record<string, number> = { ...seen }
204 for (const l of limits) {
205 const level = l.percent >= 95 ? 95 : l.percent >= 80 ? 80 : 0
206 const key = `${l.kind}@${l.resetsAt ?? 0}`
207 if (level > 0 && (marks[key] ?? 0) < level) {
208 marks[key] = level
209 $.ui.toast(`${limitLabel(l.kind).toLowerCase()} limit at ${Math.round(l.percent)}%`)
210 }
211 }
212 await update($, warned, () => marks)
213}
214
215export const register: Register = on => {
216 on('session.start', async ($, e, next) => {
217 const started = await next(e)
218 try {
219 await $.command.register({ name: 'deck', description: 'Collapse or expand the command deck' })
220 await refresh($)
221 $.clock.every(REFRESH_MS, () => void refresh($))
222 } catch {
223 // the band draws once a refresh lands
224 }
225 return started
226 })
227
228 on('turn.complete', async ($, e, next) => {
229 const done = await next(e)
230 void refresh($)
231 return done
232 })
233
234 on('command.run', { command: 'deck' }, async $ => {
235 try {
236 const isNow = !(await read($, isCollapsed))
237 await update($, isCollapsed, () => isNow)
238 return { text: isNow ? 'Command deck collapsed.' : 'Command deck expanded.' }
239 } catch {
240 return { text: 'The command deck could not do that.' }
241 }
242 })
243
244 // The model and effort picked on the deck apply to the main conversation's requests.
245 on('turn.step', async function* ($, e, next) {
246 let m = 'default'
247 let f = 'default'
248 try {
249 if (e.agentId === undefined) {
250 m = await read($, model)
251 f = await read($, effort)
252 }
253 } catch {
254 // send the request as it is
255 }
256 return yield* next({
257 ...e,
258 ...(m !== 'default' ? { model: m } : {}),
259 ...(f !== 'default' ? { effort: f as 'low' | 'medium' | 'high' | 'xhigh' | 'max' } : {}),
260 })
261 })
262
263 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
264 if (e.props.hasSurvey) return next(e)
265 const { Box, Text, Button, Select } = $.ui.resolve(e)
266 const cols = Math.max(40, e.props.bodyColumns)
267 const s = await read($, snap)
268 const collapsed = await read($, isCollapsed)
269 const m = await read($, model)
270 const f = await read($, effort)
271 const isAuto = await read($, autoModelIsOn)
272 const isArmed = await read($, upgradeIsArmed)
273
274 // A chip only shows when the mod that serves its command is installed.
275 const has = (name: string) => (s?.commands ?? []).some(c => c === name || c.endsWith(`:${name}`))
276
277 const chip = (key: string, label: string, onPress: () => void, isActive = false, dot?: string) => (
278 <Button key={key} plain onPress={onPress}>
279 <Text backgroundColor={isActive ? GOLD : SLATE} color={isActive ? DARK : TEXT} bold={isActive}>{' '}</Text>
280 {dot !== undefined && <Text backgroundColor={isActive ? GOLD : SLATE} color={dot}>{'● '}</Text>}
281 <Text backgroundColor={isActive ? GOLD : SLATE} color={isActive ? DARK : TEXT} bold={isActive}>{`${label} `}</Text>
282 </Button>
283 )
284 // Everything on the deck is one row tall and never wraps, so the band stays short and never scrolls.
285 const dim = (text: string) => <Text color={MUTED} wrap="truncate">{text}</Text>
286 const meter = (key: string, name: string, percent: number | null, detail: string, barCells: number) => (
287 <Box key={key} flexDirection="row" flexShrink={0}>
288 <Text color={MUTED} wrap="truncate">{`${name} `}</Text>
289 {percent === null ? (
290 <Text color={MUTED} wrap="truncate">waiting</Text>
291 ) : (
292 <Box flexDirection="row" flexShrink={0}>
293 <Text color={tone(percent)}>{bar(percent, barCells).on}</Text>
294 <Text color={HAIR}>{bar(percent, barCells).off}</Text>
295 <Text color={tone(percent)} bold wrap="truncate">{` ${Math.round(percent)}%`}</Text>
296 {detail !== '' && <Text color={MUTED} wrap="truncate">{` ${detail}`}</Text>}
297 </Box>
298 )}
299 </Box>
300 )
301
302 const modelName = m !== 'default' ? modelLabel(m) : s ? modelLabel(s.model) : ''
303 const effortName = f !== 'default' ? f.toUpperCase() : ''
304 const right = [isAuto ? 'AUTO' : '', modelName, effortName].filter(Boolean).join(' · ')
305 const toggle = (
306 <Button key="collapse" plain onPress={() => void update($, isCollapsed, v => !v)}>
307 <Text color={MUTED}>{collapsed ? ' [+]' : ' [–]'}</Text>
308 </Button>
309 )
310
311 const title = (
312 <Box key="title" flexDirection="column" width={cols}>
313 <Box flexDirection="row" justifyContent="space-between" width={cols}>
314 <Text bold color={TEXT}>{TITLE}</Text>
315 <Box flexDirection="row" flexShrink={0}>
316 <Text color={GOLD} wrap="truncate">{right}</Text>
317 {toggle}
318 </Box>
319 </Box>
320 <Box flexDirection="row" width={cols} overflow="hidden">
321 <Text color={GOLD}>{'━'.repeat(12)}</Text>
322 <Text color={HAIR} wrap="truncate">{'─'.repeat(400)}</Text>
323 </Box>
324 </Box>
325 )
326
327 if (collapsed) {
328 const ctx = s?.contextPercent
329 const five = s?.limits.find(l => l.kind === 'five_hour')
330 const bits = [
331 s?.branch ? `⎇ ${s.branch}` : '',
332 ctx != null ? `CTX ${Math.round(ctx)}%` : '',
333 five ? `5H ${Math.round(five.percent)}%` : '',
334 s?.costUsd != null ? `$${s.costUsd.toFixed(2)}` : '',
335 ].filter(Boolean)
336 return (
337 <Box flexDirection="row" justifyContent="space-between" width={cols}>
338 <Box flexDirection="row">
339 <Text bold color={TEXT}>{TITLE}</Text>
340 <Text color={MUTED}>{fit(` ${bits.join(' ')}`, Math.max(0, cols - TITLE.length - 12))}</Text>
341 </Box>
342 <Box flexDirection="row">
343 <Text color={GOLD}>{fit(right, 20)}</Text>
344 {toggle}
345 </Box>
346 </Box>
347 )
348 }
349
350 const rows: unknown[] = [title]
351
352 if (s !== null) {
353 const where = [
354 s.version ? `${s.folder} v${s.version}` : s.folder,
355 s.branch ? `⎇ ${s.branch}` : '',
356 s.changed != null ? `● ${s.changed} changed` : '',
357 s.ahead > 0 ? `↑${s.ahead}` : '',
358 s.behind > 0 ? `↓${s.behind}` : '',
359 ].filter(Boolean).join(' ')
360 const dev = s.devPort !== null ? (
361 <Text key="dev" color={s.isDevUp ? GREEN : RED} wrap="truncate">{` dev :${s.devPort} ${s.isDevUp ? 'up' : 'down'}`}</Text>
362 ) : null
363 const session = [
364 span(s.now - s.startedAt),
365 `${s.prompts} prompt${s.prompts === 1 ? '' : 's'}`,
366 s.costUsd != null ? `$${s.costUsd.toFixed(2)}` : '',
367 ].filter(Boolean).join(' · ')
368 rows.push(
369 <Box key="where" flexDirection="row" justifyContent="space-between" width={cols}>
370 <Box flexShrink={1} flexDirection="row"><Text color={TEXT} wrap="truncate">{where}</Text>{dev}</Box>
371 <Box flexShrink={0}>{dim(` ${session}`)}</Box>
372 </Box>,
373 )
374
375 // All meters share one row; the bars shrink to fit, and the reset countdowns drop on a narrow band.
376 const short: Record<string, string> = { five_hour: '5H', seven_day: 'WK' }
377 const meters: [string, string, number | null, string][] = [
378 ['ctx', 'CTX', s.contextPercent, s.contextTokens != null ? `${tokens(s.contextTokens)}/${tokens(s.contextWindow)}` : ''],
379 ...s.limits.map(l => [l.kind, short[l.kind] ?? limitLabel(l.kind), l.percent, l.resetsAt ? span(l.resetsAt - s.now) : ''] as [string, string, number, string]),
380 ]
381 const isRoomy = cols >= 110
382 const barCells = isRoomy ? 10 : 6
383 rows.push(
384 <Box key="usage" flexDirection="row" gap={3} width={cols} overflow="hidden">
385 {meters.map(([key, name, pct, detail]) => meter(key, name, pct, isRoomy || key !== 'ctx' ? detail : '', barCells))}
386 </Box>,
387 )
388 }
389
390 const modelValue = isAuto ? 'auto' : m
391 const modelOptions = isAuto ? [{ value: 'auto', label: 'Auto' }, ...MODELS] : MODELS
392 rows.push(
393 <Box key="mods" flexDirection="row" justifyContent="space-between" width={cols}>
394 <Box flexDirection="row" gap={1} flexShrink={0}>
395 {s?.isGitHub && has('issues') && chip('issues', 'Issues', () => void $.command.run({ command: 'issues' }))}
396 {has('dev') && s?.devPort != null && chip('dev', s.isDevUp ? 'Stop dev' : 'Start dev', () => void $.command.run({ command: 'dev', args: s.isDevUp ? 'stop' : 'start' }), s.isDevUp === true)}
397 {has('better') && chip('better',isArmed ? 'Prompt Upgrade · armed' : 'Prompt Upgrade', () => void $.command.run({ command: 'better', args: 'arm' }), isArmed)}
398 {has('automodel') && chip('automodel',isAuto ? 'Auto Model · on' : 'Auto Model', () => void $.command.run({ command: 'automodel', args: 'toggle' }), isAuto, isAuto ? DARK : undefined)}
399 </Box>
400 <Box flexDirection="row" gap={1} flexShrink={0}>
401 <Select
402 key="model"
403 label="Model "
404 options={modelOptions}
405 value={modelValue}
406 onSelect={v => {
407 if (v === 'auto') {
408 if (!isAuto) void $.command.run({ command: 'automodel', args: 'on' })
409 return
410 }
411 void update($, model, () => v)
412 if (isAuto) void $.command.run({ command: 'automodel', args: 'off' })
413 }}
414 />
415 <Select key="effort" label="Effort " options={EFFORTS} value={f} onSelect={v => void update($, effort, () => v)} />
416 </Box>
417 </Box>,
418 )
419
420 return (
421 <Box flexDirection="column" width={cols}>
422 {rows}
423 </Box>
424 )
425 })
426}
427types/index.d.ts 36 lines1export type Limit = { kind: string; percent: number; resetsAt: number | null }
2
3export type Snapshot = {
4 now: number
5 folder: string
6 version: string | null
7 commands: string[]
8 isGitHub: boolean
9 devPort: number | null
10 isDevUp: boolean | null
11 branch: string | null
12 changed: number | null
13 ahead: number
14 behind: number
15 startedAt: number
16 prompts: number
17 costUsd: number | null
18 contextPercent: number | null
19 contextTokens: number | null
20 contextWindow: number
21 limits: Limit[]
22 model: string
23}
24
25declare module 'claude-code' {
26 interface PluginState {
27 'command-deck': {
28 snap: Snapshot | null
29 isCollapsed: boolean
30 model: string
31 effort: string
32 warned: Record<string, number>
33 }
34 }
35}
36