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…

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.
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.
Open /config, or ask Claude to set these for you.
| Setting | What it takes | Default |
|---|---|---|
| Launch buttons | Label=prompt, separated by semicolons: Review=review my last change; Tests=run the tests | empty: no buttons |
| Status row label | the word in front of the row | STATUS |
| Status row command | a program and its arguments: git log -1 --oneline, node tools/count.js | empty: no row |
| Status row folder | where the command runs | the 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.
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.
turn.step hook on its way out. That hook is Claude Code's own request to the model, not a new connection.claude-haiku-4-5-20251001. Your own messages keep your model.Which programs it runs, and why.
What it puts in the prompts it submits.
The panel draws in the terminal and in the Code tab of the Claude desktop app.
Tested with Claude Code 2.1.289.
See every Rostech mod, live, at rostech.app.
hooks/register.tsx 263 lines1import { 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
263types/index.d.ts 18 lines1export 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