SLOPSHOPPER

command-deck

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

newbandcommandtoastprocesstimer
★ 1v0.3.0no licenseupdated 2026-10-15clafronteaccnts/commanddeck/command-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · command-deck
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /deck ⎿ command-deck: Command deck collapsed. COMMAND DECK CTX 49% 5H 31% $0.42 OPUS 5.5collapse ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
COMMAND DECK CTX 49% 5H 31% $0.42 OPUS 5.5collapse
README

commanddeck

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.

ModWhat it doesCommand
command-deckThe band above the prompt./deck
github-issuesA pane listing the repo's open GitHub issues./issues
prompt-upgradeTurns a rough prompt into a better one before it is sent./better
auto-modelPicks the right model for each prompt you type./automodel
dev-serverStarts, stops and restarts the project's dev server./dev

Install

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.

Add the other mods

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

What each mod does

command-deck

One band above the prompt, with:

  • Where you are: the folder, its package.json version, the git branch, how many files changed, and commits ahead or behind.
  • Session: how long it has run, the number of prompts and the cost so far.
  • Meters: how full the context window is, and your 5-hour and weekly plan limits with their reset countdowns. A short notice appears once when a limit passes 80% and again at 95%.
  • Dev server: dev :9002 up or down, shown when the project's dev script names a port (for example next dev -p 9002).
  • Mod buttons: Issues, Start dev or Stop dev, Prompt Upgrade and Auto Model. A button appears only when its mod is installed (and, for Issues, the repo is on GitHub).
  • Model and effort: selectors that apply to the main conversation's requests.

Run /deck to collapse the band to a single line, and again to expand it.

github-issues

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

prompt-upgrade

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

auto-model

/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-server

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

Update

/plugin marketplace update commanddeck

Restart Claude Code afterwards. Updates only arrive when a mod's version number changes.

Remove

/plugin uninstall command-deck@commanddeck

Use the same form for the other mods, for example github-issues@commanddeck.

Requirements

  • command-deck: 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).
  • github-issues: the GitHub CLI (gh), signed in with gh auth login.
  • dev-server: 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.
  • prompt-upgrade, auto-model: nothing extra.
Source 2 files
hooks/register.tsx 427 lines
1import { 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}
427
types/index.d.ts 36 lines
1export 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