SLOPSHOPPER

Dashboard

A Dashboard button above the prompt that opens a panel you fill yourself: effort, helper model, launch buttons and a status row. Fully customisable: just ask…

panebandcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rostech-dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ D A S H B O A R D — · Auto │ ┃ ⏺ Read(src/auth.ts) │ ┃ EFFORT [ Auto ] [ Low ] [ Medium ] [ High ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ S E T T I N G S ⎿ Added 2 lines, removed 1 line │ ┃ ○ Helper agents the model they use[ Same a ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ L A U N C H │ ┃ No buttons yet. Add them in /config, or ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ask Claude to. │ ✻ Worked for 42s · done 4:20 PM │ │ › /dashboard │ ⎿ rostech-dashboard: Dashboard opened. │ │ ⟨Claude Code's own drawing⟩ [ ◆ Dashboard ▴ ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ [ ◆ Dashboard ▴ ]
Pane · Dashboard
◆ D A S H B O A R D — · Auto EFFORT [ Auto ] [ Low ] [ Medium ] [ High ] [ XHigh ] [ M S E T T I N G S ○ Helper agents the model they use [ Same as me ] L A U N C H No buttons yet. Add them in /config, or ask Claude to.
README

Dashboard

Fully customisable: just ask your Claude to put what you want in it.

A ◆ Dashboard button sits above the prompt, beside any other mod's band. Press it and a panel opens.

◆ D A S H B O A R D Opus 5.5 · High STATUS 3 of 7 EFFORT [ Auto ] [ Low ] [ Medium ] [ High ] [ XHigh ] [ Max ]

S E T T I N G S ○ Helper agents the model they use [ Same as me ]

L A U N C H [ ◆ Review ] [ ◆ Tests ]

We use ours for pipeline updates and quick access to commands.

What ships

  • Effort. Pick one and every request you send goes out with it. Auto leaves Claude Code's own choice alone. The top line shows the model and effort your last message really went out with.
  • Helper agents. Same as me, or Fast & Cheap, which sends every subagent to Haiku. Your own messages keep your model.
  • Launch buttons. Each one sends its prompt as if you typed it.
  • A status row. One line from a command you choose, read when the panel opens and after each turn while it is open. If the output has a count like 3 of 7 the row shows that, green at 0 and red when full. Otherwise it shows the first line.

/dashboard opens the panel too, and its reply carries the status reading.

Make it yours

Open /config, or ask Claude to set these for you.

SettingWhat it takesDefault
Launch buttonsLabel=prompt, separated by semicolons: Review=review my last change; Tests=run the testsempty: no buttons
Status row labelthe word in front of the rowSTATUS
Status row commanda program and its arguments: git log -1 --oneline, node tools/count.jsempty: no row
Status row folderwhere the command runsthe folder Claude Code started in

The command is run directly, not through a shell, so pipes and && do not work. Put "quotes" round a part with spaces in it. It may run for 20 seconds at most.

What it runs and sends

Nothing goes to any service except Anthropic, through Claude Code's own requests. Here is each thing, plainly.

What it sends, and where. It changes nothing it does not name here.

  • Every request Claude Code sends to Anthropic passes through the mod's turn.step hook on its way out. That hook is Claude Code's own request to the model, not a new connection.
  • If you picked an effort, the hook sets that request's effort to your pick. On Auto it leaves the request alone.
  • If Helper agents is Fast & Cheap, the hook sets a subagent's request to the model claude-haiku-4-5-20251001. Your own messages keep your model.
  • The request still goes only to Anthropic, the same way it would without the mod. The mod adds no text to it.

Which programs it runs, and why.

  • Only the one program you write into Status row command, to show one line of status on the panel. Empty means it runs nothing.
  • It cannot be written as fixed text in the mod, because you choose it. The mod runs exactly what you typed: the program and its arguments, directly, never through a shell, in Status row folder, for at most 20 seconds.
  • It runs when the panel opens and after each turn while the panel is open. Its output stays on your screen and is never sent anywhere.

What it puts in the prompts it submits.

  • A launch button submits exactly the prompt you wrote for it in Launch buttons, as if you typed it. The mod adds nothing and changes nothing.
  • It submits nothing else, ever. With no buttons set, it never submits.

The panel draws in the terminal and in the Code tab of the Claude desktop app.

Tested with Claude Code 2.1.289.

More mods

See every Rostech mod, live, at rostech.app.

Source 2 files
hooks/register.tsx 263 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Pick, StatusReading } from '../types'
5
6// The Dashboard: a button above the prompt that opens a panel. Every row DOES
7// something. Effort and the helper model rewrite each request on its way out
8// (turn.step), so nothing here is a picture of a setting. The launch buttons
9// and the status row are the user's own, from /config.
10
11const PANE = 'dashboard'
12const ACCENT = '#E8833A'
13
14const status = atom({ plugin: 'rostech-dashboard', key: 'status' } as const, null as StatusReading)
15const effort = atom({ plugin: 'rostech-dashboard', key: 'effort' } as const, null as Pick)
16const cheapHelpers = atom({ plugin: 'rostech-dashboard', key: 'cheapHelpers' } as const, false)
17const lastSent = atom({ plugin: 'rostech-dashboard', key: 'lastSent' } as const, null as string | null)
18const lastEffort = atom({ plugin: 'rostech-dashboard', key: 'lastEffort' } as const, null as string | null)
19const isOpen = atom({ plugin: 'rostech-dashboard', key: 'isOpen' } as const, false)
20
21const EFFORTS: { id: string; name: string }[] = [
22  { id: 'low', name: 'Low' },
23  { id: 'medium', name: 'Medium' },
24  { id: 'high', name: 'High' },
25  { id: 'xhigh', name: 'XHigh' },
26  { id: 'max', name: 'Max' },
27]
28const HELPER_MODEL = 'claude-haiku-4-5-20251001'
29
30export type Launch = { name: string; text: string }
31
32// "Label=prompt; Label=prompt". A part with no = uses its words for both.
33export function parseButtons(raw: unknown): Launch[] {
34  return String(raw ?? '')
35    .split(';')
36    .map(part => part.trim())
37    .filter(Boolean)
38    .map(part => {
39      const at = part.indexOf('=')
40      if (at < 0) return { name: part, text: part }
41      const name = part.slice(0, at).trim()
42      const text = part.slice(at + 1).trim()
43      return { name: name || text, text: text || name }
44    })
45    .filter(one => one.text)
46}
47
48// A program and its arguments, split on spaces; "double" or 'single' quotes
49// keep a part with spaces in it whole. No shell, so no pipes or globs.
50export function parseCommand(raw: unknown): string[] {
51  const out: string[] = []
52  const re = /"([^"]*)"|'([^']*)'|(\S+)/g
53  const text = String(raw ?? '')
54  let m: RegExpExecArray | null
55  while ((m = re.exec(text))) out.push(m[1] ?? m[2] ?? m[3] ?? '')
56  return out
57}
58
59// "n of m" anywhere in the output wins; otherwise its first non-empty line.
60export function readOutput(stdout: string): StatusReading {
61  const hit = /(\d+) of (\d+)/.exec(stdout)
62  const first = stdout.split(/\r?\n/).map(l => l.trim()).find(Boolean) ?? ''
63  if (hit) return { count: { n: Number(hit[1]), of: Number(hit[2]) }, line: first }
64  return first ? { count: null, line: first } : null
65}
66
67// claude-opus-5-5 → Opus 5.5; claude-haiku-4-5-20251001 → Haiku 4.5.
68function modelName(id: string | null): string {
69  if (!id) return '—'
70  const parts = id.replace(/^claude-/, '').split('-').filter(p => !/^\d{8}$/.test(p))
71  const words = parts.filter(p => !/^\d+$/.test(p))
72  const nums = parts.filter(p => /^\d+$/.test(p))
73  if (!words.length) return id
74  const word = words.map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
75  return nums.length ? `${word} ${nums.join('.')}` : word
76}
77function effortName(id: string | null): string {
78  return EFFORTS.find(e => e.id === id)?.name ?? 'Auto'
79}
80function spaced(word: string): string {
81  return word.split('').join(' ')
82}
83function clip(text: string, max: number): string {
84  return text.length > max ? text.slice(0, Math.max(1, max - 1)) + '…' : text
85}
86
87// The status row, read by the user's own command at the moment of looking.
88type StatusCommand = { argv: string[]; folder: string }
89async function readStatus($: EngineInterface, cfg: StatusCommand) {
90  if (!cfg.argv.length) return
91  try {
92    const run = await $.process.run(cfg.argv, { ...(cfg.folder ? { cwd: cfg.folder } : {}), timeoutMs: 20000 })
93    // A failed command says why, never a silent "no reading yet".
94    const err = run.stderr.trim().split(/\r?\n/)[0] ?? ''
95    const said = readOutput(run.stdout) ?? (run.exitCode !== 0 ? { count: null, line: `failed (${run.exitCode}): ${err}` } : null)
96    await update($, status, () => said)
97  } catch (err) {
98    await update($, status, () => ({ count: null, line: `could not run: ${String((err as Error)?.message ?? err)}` }))
99  }
100}
101
102async function openPane($: EngineInterface, cfg: StatusCommand) {
103  await $.ui.open({ id: PANE, title: 'Dashboard' })
104  await update($, isOpen, () => true)
105  await readStatus($, cfg)
106}
107
108export const register: Register = (on, options) => {
109  const buttons = parseButtons(options.buttons)
110  const argv = parseCommand(options.statusCommand)
111  const folder = String(options.statusFolder ?? '').trim()
112  const statusLabel = String(options.statusLabel ?? '').trim() || 'STATUS'
113
114  const cfg: StatusCommand = { argv, folder }
115
116  on('session.start', async ($, e, next) => {
117    // Read the status as soon as the mod loads, not only when the panel opens.
118    void readStatus($, cfg)
119    await $.command.register({ name: 'dashboard', description: 'Open the Dashboard: effort, helper agents, your launch buttons and status row.' })
120    return next(e)
121  })
122
123  // The real work: every request leaves with the picks on it.
124  on('turn.step', async function* ($, e, next) {
125    if (e.agentId) {
126      if (await read($, cheapHelpers)) return yield* next({ ...e, model: HELPER_MODEL })
127      return yield* next(e)
128    }
129    const ef = await read($, effort)
130    const sent = { ...e, ...(ef ? { effort: ef as typeof e.effort } : {}) }
131    await update($, lastSent, () => sent.model)
132    await update($, lastEffort, () => (typeof sent.effort === 'string' ? sent.effort : null))
133    return yield* next(sent)
134  })
135
136  // The reply carries the status reading too, for a surface that draws no pane.
137  on('command.run', { command: 'dashboard' }, async $ => {
138    await openPane($, cfg)
139    if (!argv.length) return { text: 'Dashboard opened.' }
140    const s = await read($, status)
141    const shown = s === null ? 'no reading' : s.count ? `${s.count.n} of ${s.count.of}` : s.line
142    return { text: `Dashboard opened. ${statusLabel}: ${shown}` }
143  })
144
145  on('turn.complete', async ($, e, next) => {
146    if (await read($, isOpen)) await readStatus($, cfg)
147    return next(e)
148  })
149
150  on('ui.close', { id: PANE }, async ($, e, next) => {
151    await update($, isOpen, () => false)
152    return next(e)
153  })
154
155  // The button. Yields to a survey; otherwise it sits beside whatever the
156  // other plugins drew in the band, never over it.
157  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
158    if (e.props.hasSurvey) return next(e)
159    const { Box, Button } = $.ui.resolve(e)
160    const up = await read($, isOpen)
161    const below = await next(e)
162    return (
163      <Box flexDirection="row" justifyContent="space-between">
164        <Box flexGrow={1}>{below}</Box>
165        <Button
166          key="dashboard"
167          variant="primary"
168          label={up ? '◆ Dashboard ▴' : '◆ Dashboard ▾'}
169          onPress={async () => {
170            if (await read($, isOpen)) {
171              await $.ui.close({ id: PANE })
172              await update($, isOpen, () => false)
173            } else {
174              await openPane($, cfg)
175            }
176          }}
177        />
178      </Box>
179    )
180  })
181
182  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
183    const { Box, Text, Button } = $.ui.resolve(e)
184    const ef = await read($, effort)
185    const s = await read($, status)
186    const cheap = await read($, cheapHelpers)
187    const sent = await read($, lastSent)
188    const sentEffort = await read($, lastEffort)
189    const width = Math.max(20, (e.props.bodyColumns ?? 80) - 12)
190
191    // What the last message really went out with; the pick until one has gone.
192    const shownModel = modelName(sent)
193    const shownEffort = sentEffort ? effortName(sentEffort) : effortName(ef)
194    const label = (text: string) => (
195      <Box width={9}>
196        <Text dimColor>{clip(text, 8)}</Text>
197      </Box>
198    )
199    const heading = (text: string) => (
200      <Box marginTop={1}>
201        <Text dimColor bold>{spaced(text)}</Text>
202      </Box>
203    )
204
205    return (
206      <Box flexDirection="column" paddingX={1}>
207        <Box justifyContent="space-between">
208          <Text color={ACCENT} bold>◆ {spaced('DASHBOARD')}</Text>
209          <Text dimColor>{shownModel} · {shownEffort}</Text>
210        </Box>
211
212        {argv.length ? (
213          <Box key="status" marginTop={1}>
214            {label(statusLabel)}
215            {s === null ? <Text dimColor>no reading yet</Text> : s.count ? (
216              <Text color={s.count.n === 0 ? 'green' : s.count.n >= s.count.of ? 'red' : 'yellow'} bold>
217                {s.count.n} of {s.count.of}
218              </Text>
219            ) : (
220              <Text>{clip(s.line, width)}</Text>
221            )}
222          </Box>
223        ) : null}
224        <Box marginTop={argv.length ? 0 : 1}>
225          {label('EFFORT')}
226          <Box flexWrap="wrap" columnGap={1}>
227            <Button key="effort-auto" variant={ef === null ? 'primary' : undefined} dimColor={ef !== null} label="Auto"
228              onPress={() => update($, effort, () => null)} />
229            {EFFORTS.map(one => (
230              <Button key={`effort-${one.id}`} variant={ef === one.id ? 'primary' : undefined} dimColor={ef !== one.id}
231                label={one.name} onPress={() => update($, effort, () => one.id)} />
232            ))}
233          </Box>
234        </Box>
235
236        {heading('SETTINGS')}
237        <Box justifyContent="space-between">
238          <Text>
239            <Text color={cheap ? 'green' : undefined}>{cheap ? '● ' : '○ '}</Text>
240            <Text bold>Helper agents</Text>
241            <Text dimColor>  the model they use</Text>
242          </Text>
243          <Button key="helpers" variant="primary" label={cheap ? 'Fast & Cheap' : 'Same as me'}
244            onPress={() => update($, cheapHelpers, was => !was)} />
245        </Box>
246
247        {heading('LAUNCH')}
248        {buttons.length ? (
249          <Box columnGap={3} flexWrap="wrap">
250            {buttons.map((one, i) => (
251              <Button key={`launch-${i}`} label={`◆ ${one.name}`}
252                onPress={() => $.prompt.submit({ text: one.text, asUser: true })} />
253            ))}
254          </Box>
255        ) : (
256          <Text key="launch-empty" dimColor>No buttons yet. Add them in /config, or ask Claude to.</Text>
257        )}
258      </Box>
259    )
260  })
261}
262
263
types/index.d.ts 18 lines
1export type Pick = string | null
2
3/** The status row's last reading: a count when the command printed "n of m", its first line otherwise. */
4export type StatusReading = { count: { n: number; of: number } | null; line: string } | null
5
6declare module 'claude-code' {
7  interface PluginState {
8    'rostech-dashboard': {
9      status: StatusReading
10      effort: Pick
11      cheapHelpers: boolean
12      lastSent: string | null
13      isOpen: boolean
14      lastEffort: string | null
15    }
16  }
17}
18