Tools menu for Claude Code: Clean View, Prompt Upgrade, model/effort control panel, Agent Dock, Roadtrip minigame.


Claude Code now has mods: plugins made of TypeScript function hooks that can draw UI right inside the terminal, react to everything Claude does, and change how it works. This repo contains four mods I use every day. There's a pixel critter that lives above your prompt, a control panel for model, effort and parallel agents, a project launcher box, and a live status bar. It also has everything you need to build your own in about 10 minutes.
⚠️ Mods are early access. The function-hooks API can change between Claude Code releases. Everything here was built and tested on Claude Code 2.1.294.
Open Claude Code in a terminal and paste:
/plugin install pixel-buddy --marketplace Bocskay-Botond/claude-code-mods
Answer y to add the marketplace, press Enter for the user scope, and you're done. A little orange critter appears above your prompt. Start typing and watch it pull out a laptop. 🧡
Want the full setup? 👉 quick-start/
| Mod | What it does | Commands |
|---|---|---|
| 🐾 pixel-buddy | A 12×4 pixel Claude critter above the prompt. It types on a tiny laptop while you type, patrols while Claude works, celebrates when a turn finishes, plays (keepy-uppy, jump rope, butterflies, bubbles, reading) when you're idle, and falls asleep after 3 minutes. | /buddy /pet |
| 🛠️ tristens-tools | A tools menu and status line: model & effort picker, Clean View (hides tool noise and shows a simple checklist), Prompt Upgrade, Agent Dock (split each request across 1–100 helper agents and watch them live), and a Roadtrip racing minigame. | /tools /dock /roadtrip |
| 📁 project-box | A folder-tree card beside the status bar listing your projects by category. Click a category, then a project, to see its note and path. pixel-buddy walks over and "types the cd" for you. | /projects |
| 📊 statusbar | A minimal one-line status bar: working/idle, tool count, last tool, last turn duration, turns. The simplest real mod in the repo, so read this one first. | — |
| 👋 hello-mod | The tutorial example: a slash command, a toast and a band in about 35 lines. | /hello |
Install any of them the same way:
/plugin install <mod-name> --marketplace Bocskay-Botond/claude-code-mods
claude-code-mods/
├── .claude-plugin/
│ └── marketplace.json # makes this repo a plugin marketplace
├── mods/
│ ├── pixel-buddy/ # 🐾 the critter
│ ├── tristens-tools/ # 🛠️ tools menu, Agent Dock, Roadtrip
│ ├── project-box/ # 📁 project launcher card
│ └── statusbar/ # 📊 one-line status bar
├── examples/
│ └── hello-mod/ # 👋 your first mod, fully commented
├── quick-start/ # ⚡ 5-minute setup
├── docs/
│ ├── installation.md # every way to install & manage mods
│ ├── writing-your-own-mod.md
│ ├── api-cheatsheet.md # events, $ nouns, UI elements
│ └── troubleshooting.md
├── mod-builder-prompt/ # 🤖 a prompt that makes Claude build mods for you
└── assets/ # 🖼️ social preview (HTML source + PNG)
Every mod is the same four small files:
my-mod/
├── .claude-plugin/plugin.json # name, version, description
├── hooks/hooks.json # { "modules": ["./register.tsx"] }
├── hooks/register.tsx # the code
└── types/index.d.ts # the state your mod keeps (optional)
on(event, hook) / next(e) middleware chain worksclaude plugin test and ship it as a GitHub marketplaceclaude --version)No Node, no npm install, no build step. Claude Code compiles the TypeScript itself.
The fastest way is to ask Claude. Inside Claude Code:
Make me a mod that shows a 🍅 pomodoro timer above the prompt and plays a toast when it ends.
Claude Code has a built-in skill for this: it writes the mod, hot-reloads it into your session and tests it. For a better result, paste the prompt from mod-builder-prompt/ first.
Prefer to do it by hand? docs/writing-your-own-mod.md walks through hello-mod line by line, and docs/api-cheatsheet.md has the events and elements you'll use most.
project-box shows a demo list until you give it yours. Copy the example and edit it:
# macOS / Linux
cp mods/project-box/project-box.example.json ~/.claude/project-box.json
# Windows (PowerShell)
Copy-Item mods\project-box\project-box.example.json $HOME\.claude\project-box.json
Restart Claude Code (or run /reload-plugins) and your projects show up. Colors can be any terminal color name (magenta, cyan, green, yellow, blue, red). Set "live": true for a green dot.
| Problem | Fix |
|---|---|
| Nothing appears after install | Run /reload-plugins, or restart Claude Code. Make sure you're in a terminal: /plugin isn't available in the desktop app's Code tab. |
Marketplace file not found | Check the repo name: Bocskay-Botond/claude-code-mods |
| A mod silently does nothing | Start with claude --debug. A failing hook is skipped and logged with the reason. |
| Two bands fight for space | pixel-buddy, project-box and tristens-tools are designed to stack. Turn things off with /buddy, /projects, or /plugin → disable. |
More: docs/troubleshooting.md
Built a cool mod? PRs are welcome!
mods/<your-mod>/ (four files, see above)claude plugin validate mods/<your-mod> and claude plugin test mods/<your-mod> pass.claude-plugin/marketplace.json and to the table in this READMEMIT. Do whatever you want, just don't blame me if your critter falls asleep on the job. 😴
tristens-tools (Clean View, Agent Dock, Roadtrip and the tools menu). Go watch it!If this made your terminal a little more fun, ⭐ star the repo and share it with someone who lives in Claude Code.
Now go build a mod. Your terminal has been boring for long enough. 🚀
hooks/register.tsx 473 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, Game, Meter, Run, Seen, Step } from '../types'
5
6const BRAND = "Tristen's Tools"
7const ORANGE = '#e8714a'
8const PURPLE = '#a06bd6'
9const GREEN = '#2fd27a'
10
11const isClean = atom({ plugin: 'tristens-tools', key: 'isClean' } as const, false)
12const isUpgrade = atom({ plugin: 'tristens-tools', key: 'isUpgrade' } as const, false)
13const model = atom({ plugin: 'tristens-tools', key: 'model' } as const, 'default')
14const effort = atom({ plugin: 'tristens-tools', key: 'effort' } as const, 'default')
15const helper = atom({ plugin: 'tristens-tools', key: 'helper' } as const, 'haiku')
16const team = atom({ plugin: 'tristens-tools', key: 'team' } as const, '0')
17const IDLE: Run = { phase: 'idle', prompt: '', startedAt: 0, tools: 0, lastTool: '', steps: [] }
18const run = atom({ plugin: 'tristens-tools', key: 'run' } as const, IDLE)
19const agents = atom({ plugin: 'tristens-tools', key: 'agents' } as const, [] as AgentRow[])
20const meter = atom({ plugin: 'tristens-tools', key: 'meter' } as const, { ctx: 0, five: 0, now: 0 } as Meter)
21const seen = atom({ plugin: 'tristens-tools', key: 'seen' } as const, { model: '', effort: '' } as Seen)
22const FRESH: Game = { isRunning: false, isOver: false, x: 1, score: 0, best: 0, obstacles: [] }
23const game = atom({ plugin: 'tristens-tools', key: 'game' } as const, FRESH)
24
25const MODELS: Record<string, { id: string; label: string }> = {
26 haiku: { id: 'claude-haiku-5-5', label: 'Haiku 5.5' },
27 sonnet: { id: 'claude-sonnet-5-5', label: 'Sonnet 5.5' },
28 opus: { id: 'claude-opus-5-5', label: 'Opus 5.5' },
29 fable: { id: 'claude-fable-5-1', label: 'Fable 5.1' },
30}
31const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
32const EFFORT_LABEL: Record<string, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max' }
33const SIZES = ['1', '3', '5', '10', '20', '50', '100']
34const ROWS = 8
35const LANES = 3
36
37// Sets the session's own model (what /model sets: footer, system prompt, next requests);
38// the turn.step rewrite below still forces it if the engine refuses.
39const pickModel = async ($: EngineInterface, k: string) => {
40 await update($, model, () => k)
41 const r = await $.config.set({ key: 'model', value: k }).catch(() => ({ deny: 'failed' }))
42 $.ui.toast('deny' in r && r.deny !== undefined ? `Model: ${MODELS[k]?.label} (this session's requests only)` : `Model: ${MODELS[k]?.label}`)
43}
44
45const spaced = (s: string) => s.toUpperCase().split(' ').map(w => w.split('').join(' ')).join(' ')
46const family = (id: string) => Object.keys(MODELS).find(k => id.includes(k)) ?? ''
47const bar = (frac: number, cells: number) => {
48 const n = Math.max(0, Math.min(cells, Math.round(frac * cells)))
49 return '▰'.repeat(n) + '▱'.repeat(cells - n)
50}
51const clock = (ms: number) => {
52 const s = Math.max(0, Math.floor(ms / 1000))
53 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
54}
55
56export const register: Register = on => {
57 on('session.start', async ($, e, next) => {
58 await $.command.register({ name: 'tools', description: 'Open the tools menu' })
59 await $.command.register({ name: 'dock', description: 'Open the Agent Dock' })
60 await $.command.register({ name: 'roadtrip', description: 'Play a mini racing game while Claude works' })
61
62 const refreshMeter = async () => {
63 const u = await $.session.usage()
64 const five = Math.round(u.rateLimits.find(r => r.kind === 'five_hour')?.percentUsed ?? 0)
65 const ctx = Math.round(u.context.percent ?? 0)
66 const prev = await read($, meter)
67 if (prev.ctx !== ctx || prev.five !== five) {
68 await update($, meter, () => ({ ctx, five, now: 0 }))
69 }
70 }
71 await refreshMeter()
72
73 const fam = family(await $.session.model())
74 if (fam !== '' && (await read($, model)) === 'default') await update($, model, () => fam)
75
76 $.clock.every(250, async () => {
77 const g = await read($, game)
78 if (!g.isRunning) return
79 await update($, game, cur => {
80 let obstacles = cur.obstacles.map(o => ({ ...o, y: o.y + 1 }))
81 const hit = obstacles.some(o => o.y === ROWS - 1 && o.lane === cur.x)
82 const passed = obstacles.filter(o => o.y >= ROWS).length
83 obstacles = obstacles.filter(o => o.y < ROWS)
84 if (Math.random() < 0.5) obstacles.push({ lane: Math.floor(Math.random() * LANES), y: 0 })
85 const score = cur.score + passed
86 return hit
87 ? { ...cur, obstacles, isRunning: false, isOver: true, best: Math.max(cur.best, score), score }
88 : { ...cur, obstacles, score }
89 })
90 })
91
92 $.clock.every(1000, async () => {
93 const now = await $.clock.now()
94 const list = await $.agent.list()
95 const prev = await read($, agents)
96 const rows: AgentRow[] = list.map(a => ({
97 id: a.id,
98 label: a.description || a.type,
99 status: a.status,
100 startedAt: prev.find(p => p.id === a.id)?.startedAt ?? now,
101 }))
102 const r = await read($, run)
103 if (JSON.stringify(prev) !== JSON.stringify(rows) || rows.some(x => x.status === 'running') || r.phase === 'working') {
104 await update($, agents, () => rows)
105 }
106 await refreshMeter()
107 })
108
109 return next(e)
110 })
111
112 on('session.measure', async ($, e, next) => {
113 const u = await $.session.usage()
114 const five = Math.round(u.rateLimits.find(r => r.kind === 'five_hour')?.percentUsed ?? 0)
115 await update($, meter, () => ({ ctx: Math.round(u.context.percent ?? 0), five, now: 0 }))
116 return next(e)
117 })
118
119 on('command.run', { command: 'tools' }, async $ => {
120 await $.ui.open({ id: 'tools', title: BRAND, focus: true })
121 return { text: `${BRAND} opened.` }
122 })
123 on('command.run', { command: 'dock' }, async $ => {
124 await $.ui.open({ id: 'dock', title: 'Agent Dock', focus: true })
125 return { text: 'Agent Dock open.' }
126 })
127 on('command.run', { command: 'roadtrip' }, async $ => {
128 await $.ui.open({ id: 'roadtrip', title: 'Roadtrip', focus: true })
129 return { text: 'Roadtrip opened: a/d steer, s start.' }
130 })
131
132 // ---- run tracking (Clean View checklist) ----
133 on('prompt.submit', async ($, e, next) => {
134 const now = await $.clock.now()
135 const steps: Step[] = [
136 { text: 'Understand your request', status: 'active' },
137 { text: 'Plan the steps', status: 'next' },
138 ]
139 await update($, run, () => ({ phase: 'working', prompt: e.text, startedAt: now, tools: 0, lastTool: '', steps }))
140 return next(e)
141 })
142 on('tool.call', async ($, e, next) => {
143 const todos = 'todos' in e && Array.isArray(e.todos) ? (e.todos as { content?: string; status?: string }[]) : undefined
144 await update($, run, (r): Run => {
145 let steps: Step[] = r.steps
146 if (todos !== undefined) {
147 steps = todos.map(t => ({
148 text: t.content ?? '',
149 status: t.status === 'completed' ? ('done' as const) : t.status === 'in_progress' ? ('active' as const) : ('next' as const),
150 }))
151 } else if (r.tools === 0) {
152 steps = [
153 { text: 'Understand your request', status: 'done' },
154 { text: 'Plan the steps', status: 'active' },
155 ]
156 }
157 return { ...r, tools: r.tools + 1, lastTool: e.tool, steps }
158 })
159 return next(e)
160 })
161 on('turn.complete', async ($, e, next) => {
162 await update($, run, (r): Run => ({ ...r, phase: 'idle', steps: r.steps.map(s => ({ ...s, status: 'done' as const })) }))
163 return next(e)
164 })
165
166 // ---- control panel: model / effort override, helper agents' model ----
167 on('turn.step', async function* ($, e, next) {
168 const [m, f, hp, s] = await Promise.all([read($, model), read($, effort), read($, helper), read($, seen)])
169 const wanted = e.agentId !== undefined && hp === 'haiku' ? 'haiku' : m
170 const changed = { ...e }
171 if (MODELS[wanted] !== undefined) changed.model = MODELS[wanted].id
172 if (e.agentId === undefined && (EFFORTS as readonly string[]).includes(f)) changed.effort = f as (typeof EFFORTS)[number]
173 if (e.agentId === undefined) {
174 const eff = String(changed.effort ?? '')
175 if (s.model !== changed.model || s.effort !== eff) await update($, seen, () => ({ model: changed.model, effort: eff }))
176 }
177 return yield* next(changed)
178 })
179
180 // ---- Agent Dock + Prompt Upgrade: extra system prompt sections ----
181 on('prompt.compose', async ($, e, next) => {
182 const out = await next(e)
183 const n = Number(await read($, team))
184 const up = await read($, isUpgrade)
185 const sections = [...out.sections]
186 if (n > 0) {
187 sections.push({
188 id: 'tristens-tools:team',
189 text: `Split each request across about ${n} parallel helper subagents (Agent tool), launching up to 10 at a time, then combine their results.`,
190 scope: 'session' as const,
191 })
192 }
193 if (up) {
194 sections.push({
195 id: 'tristens-tools:upgrade',
196 text: "Before acting, silently rewrite the user's request into a clearer, more specific brief (goal, constraints, output format) and work from that brief.",
197 scope: 'session' as const,
198 })
199 }
200 return { sections }
201 })
202
203 // ---- Clean View: hide technical rows ----
204 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
205 if (!(await read($, isClean))) return next(e)
206 const { Text } = $.ui.resolve(e)
207 return <Text>{''}</Text>
208 })
209 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
210 if (!(await read($, isClean))) return next(e)
211 const { Text } = $.ui.resolve(e)
212 return <Text>{''}</Text>
213 })
214 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
215 if (!(await read($, isClean))) return next(e)
216 const { Text } = $.ui.resolve(e)
217 return <Text>{''}</Text>
218 })
219
220 // ---- band above the prompt: checklist box, status line, tools button ----
221 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
222 if (e.props.hasSurvey) return next(e)
223 const below = await next(e)
224 const { Box, Text, Button } = $.ui.resolve(e)
225 const [r, clean, ov, ef, m, s, a] = await Promise.all([
226 read($, run), read($, isClean), read($, model), read($, effort), read($, meter), read($, seen), read($, agents),
227 ])
228 const now = await $.clock.now()
229 const fam = ov !== 'default' ? ov : family(s.model)
230 const modelLabel = MODELS[fam]?.label ?? 'Claude'
231 const eff = ef !== 'default' ? ef : s.effort || 'medium'
232 const running = a.filter(x => x.status === 'running').length
233 const working = r.phase === 'working'
234 const activeIdx = Math.max(0, r.steps.findIndex(x => x.status === 'active'))
235 const doneN = r.steps.filter(x => x.status === 'done').length
236 const pct = r.steps.length > 0 ? Math.round((doneN / r.steps.length) * 100) : 0
237
238 return (
239 <Box flexDirection="column">
240 {below}
241 {clean && working ? (
242 <Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1}>
243 <Box justifyContent="space-between">
244 <Text color="magenta">◆ {r.prompt.slice(0, 48)}{r.prompt.length > 48 ? '…' : ''}</Text>
245 <Text dimColor>{Math.max(1, Math.round((now - r.startedAt) / 1000))}s</Text>
246 </Box>
247 <Box justifyContent="space-between">
248 <Text dimColor>Step {Math.min(activeIdx + 1, r.steps.length)} of {r.steps.length}</Text>
249 <Text color="magenta">{pct}%</Text>
250 </Box>
251 {r.steps.map(st => (
252 <Box justifyContent="space-between">
253 <Text bold={st.status === 'active'} dimColor={st.status === 'next'} color={st.status === 'done' ? 'green' : undefined}>
254 {st.status === 'done' ? '✓' : st.status === 'active' ? '▶' : ' '} {st.text}
255 </Text>
256 <Text color={st.status === 'active' ? 'magenta' : undefined} dimColor={st.status !== 'active'}>
257 {st.status === 'active' ? 'Working' : st.status === 'done' ? 'Done' : 'Next'}
258 </Text>
259 </Box>
260 ))}
261 </Box>
262 ) : null}
263 {clean ? (
264 <Box justifyContent="flex-end">
265 <Text dimColor>── Clean View</Text>
266 </Box>
267 ) : null}
268 <Box justifyContent="space-between">
269 <Text>
270 <Text color="green">◆ </Text>
271 <Text>Subscription</Text>
272 <Text dimColor> │ </Text>
273 <Text color="blue">◆ </Text>
274 <Text bold>{modelLabel}</Text>
275 <Text dimColor> │ effort </Text>
276 <Text color="yellow" bold>{eff}</Text>
277 <Text dimColor> │ ctx </Text>
278 <Text color="green">{bar(m.ctx / 100, 10)} </Text>
279 <Text color="green" bold>{m.ctx}%</Text>
280 <Text dimColor> │ 5h </Text>
281 <Text color="green" bold>{m.five}%</Text>
282 <Text dimColor> │ ◇ {running} agents</Text>
283 </Text>
284 <Button key="tools" variant="primary" label={`◆ ${BRAND} ▾`} onPress={() => $.ui.open({ id: 'tools', title: BRAND, focus: true })} />
285 </Box>
286 </Box>
287 )
288 })
289
290 // ---- Tristen's Tools panel ----
291 on('ui.render', { component: 'Pane', requestId: 'tools' }, async ($, e) => {
292 const { Box, Text, Button } = $.ui.resolve(e)
293 const [clean, up, ov, ef, hp, s] = await Promise.all([
294 read($, isClean), read($, isUpgrade), read($, model), read($, effort), read($, helper), read($, seen),
295 ])
296 const isWide = e.props.bodyColumns >= 64
297 const fam = ov !== 'default' ? ov : family(s.model)
298 const eff = ef !== 'default' ? ef : s.effort || 'medium'
299 const modelName = (k: string) => (isWide ? MODELS[k]?.label : MODELS[k]?.label.split(' ')[0]) ?? k
300 const effortName = (k: string) => (isWide || k !== 'medium' ? EFFORT_LABEL[k] : 'Med') ?? k
301 const toggle = (key: string, isOn: boolean, flip: () => unknown) => (
302 <Button key={key} label={isOn ? '● On' : '○ Off'} variant={isOn ? 'primary' : 'secondary'} onPress={flip} />
303 )
304 const row = (dot: string, dotColor: string | undefined, name: string, hint: string, control: ReturnType<typeof toggle>) => (
305 <Box justifyContent="space-between">
306 <Text wrap="truncate-end">
307 <Text color={dotColor} dimColor={dotColor === undefined}>{dot} </Text>
308 {name}
309 {isWide ? <Text dimColor> {hint}</Text> : null}
310 </Text>
311 {control}
312 </Box>
313 )
314
315 return (
316 <Box flexDirection="column" paddingX={1}>
317 <Box justifyContent="space-between" flexWrap="wrap">
318 <Text bold color={ORANGE}>◆ {isWide ? spaced(BRAND) : BRAND.toUpperCase()}</Text>
319 <Text dimColor>{MODELS[fam]?.label ?? 'Claude'} · {EFFORT_LABEL[eff] ?? eff}</Text>
320 </Box>
321 <Box flexWrap="wrap">
322 <Text dimColor>{isWide ? 'MODEL ' : 'MODEL '}</Text>
323 {Object.keys(MODELS).map(k =>
324 k === fam ? (
325 <Text key={`m-${k}`} backgroundColor={ORANGE} bold> {modelName(k)} </Text>
326 ) : (
327 <Button key={`m-${k}`} plain dimColor label={` ${modelName(k)} `} onPress={() => pickModel($, k)} />
328 ),
329 )}
330 </Box>
331 <Box flexWrap="wrap">
332 <Text dimColor>{isWide ? 'EFFORT ' : 'EFFORT '}</Text>
333 {EFFORTS.map(k =>
334 k === eff ? (
335 <Text key={`e-${k}`} backgroundColor={PURPLE} bold> {effortName(k)} </Text>
336 ) : (
337 <Button key={`e-${k}`} plain dimColor label={` ${effortName(k)} `} onPress={async () => {
338 await update($, effort, () => k)
339 $.ui.toast(`Effort: ${EFFORT_LABEL[k]} (from the next request)`)
340 }} />
341 ),
342 )}
343 </Box>
344 <Box marginTop={1}>
345 <Text dimColor>{spaced('Settings')}</Text>
346 </Box>
347 {row('●', clean ? GREEN : undefined, 'Clean View', 'simple checklist', toggle('clean', clean, () => update($, isClean, c => !c)))}
348 {row('●', up ? GREEN : undefined, 'Prompt Upgrade', 'better prompts', toggle('upgrade', up, () => update($, isUpgrade, c => !c)))}
349 {row(
350 '●',
351 undefined,
352 'Helper agents',
353 'model they use',
354 <Button key="helper" label={hp === 'haiku' ? 'Fast & Cheap' : 'Same model'} onPress={() => update($, helper, v => (v === 'haiku' ? 'same' : 'haiku'))} />,
355 )}
356 <Box marginTop={1}>
357 <Text dimColor>{spaced('Launch')}</Text>
358 </Box>
359 <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? 4 : 0}>
360 <Button key="create" plain label="✦ Create photo or video" onPress={() => $.prompt.fill({ text: 'Create a photo or video: ', mode: 'insert' })} />
361 <Button key="dock" plain label="◆ Agent Dock" onPress={() => $.ui.open({ id: 'dock', title: 'Agent Dock', focus: true })} />
362 <Button key="rt" plain label="▶ Roadtrip" onPress={() => $.ui.open({ id: 'roadtrip', title: 'Roadtrip', focus: true })} />
363 </Box>
364 </Box>
365 )
366 })
367
368 // ---- Agent Dock ----
369 on('ui.render', { component: 'Pane', requestId: 'dock' }, async ($, e) => {
370 const { Box, Text, Button } = $.ui.resolve(e)
371 const isWide = e.props.bodyColumns >= 64
372 const [t, hp, list, r] = await Promise.all([read($, team), read($, helper), read($, agents), read($, run)])
373 const now = await $.clock.now()
374 const size = Number(t)
375 const working = list.filter(x => x.status === 'running')
376 const done = list.filter(x => x.status !== 'running')
377 const queued = Math.max(0, size - list.length)
378 const live = r.phase === 'working' && size > 0 && list.length > 0
379 const colors = ['red', 'yellow', 'green', 'cyan', 'blue', 'magenta']
380 const dots = Math.min(Math.max(size, 1), 25)
381 const total = Math.max(size, list.length, 1)
382
383 return (
384 <Box flexDirection="column" paddingX={1}>
385 <Box justifyContent="space-between">
386 <Text bold color={ORANGE}>◆ {isWide ? spaced('Agent Dock') : 'AGENT DOCK'}</Text>
387 <Text color={live ? 'blue' : undefined} dimColor={!live}>{live ? '● LIVE' : isWide ? spaced('Standing by') : 'STANDING BY'}</Text>
388 </Box>
389 <Box flexWrap="wrap">
390 <Text dimColor>{isWide ? spaced('Team size') + ' ' : 'TEAM '}</Text>
391 <Button key="s0" plain dimColor label=" Off " onPress={() => update($, team, () => '0')} />
392 {SIZES.map(k =>
393 k === t ? (
394 <Text backgroundColor={ORANGE} bold> {k} </Text>
395 ) : (
396 <Button key={`s-${k}`} plain dimColor label={` ${k} `} onPress={() => update($, team, () => k)} />
397 ),
398 )}
399 </Box>
400 <Text dimColor>
401 {size > 0 ? `Splits each request across ${size} helpers${isWide ? ' 10 at a time' : ''} ${hp === 'haiku' ? 'Fast & Cheap' : 'Same model'}` : 'Pick a team size; it applies to every request until set to Off.'}
402 </Text>
403 {size > 20 ? <Text color="yellow">Heads up: a big team burns through your plan limits much faster.</Text> : null}
404 {!live ? (
405 <Box flexDirection="column" alignItems="center" borderStyle="round" borderColor="green" paddingY={1} marginTop={1}>
406 <Text>
407 {Array.from({ length: dots }, (_, i) => (
408 <Text color={colors[i % colors.length]}>● </Text>
409 ))}
410 </Text>
411 <Text bold>{size > 0 ? `Your team of ${size} is standing by` : 'No team selected'}</Text>
412 <Text dimColor>{size > 0 ? `Send a request and it splits across ${size} helpers.` : 'Requests run on a single agent.'}</Text>
413 </Box>
414 ) : (
415 <Box flexDirection="column" marginTop={1}>
416 <Box justifyContent="space-between">
417 <Text dimColor>{isWide ? spaced('Mission') : 'MISSION'} <Text bold>{r.prompt.slice(0, 40)}</Text></Text>
418 <Text dimColor>{clock(now - r.startedAt)}</Text>
419 </Box>
420 <Text color="green">{bar(done.length / total, 30)} {Math.round((done.length / total) * 100)}%</Text>
421 <Text>
422 <Text color="blue">● {working.length}</Text><Text dimColor> working ○ {queued} queued </Text><Text color="green">✓ {done.length}</Text><Text dimColor> done</Text>
423 </Text>
424 <Text>
425 {list.slice(0, 25).map(x => (
426 <Text color={x.status === 'running' ? 'blue' : 'green'}>● </Text>
427 ))}
428 {Array.from({ length: Math.max(0, Math.min(queued, 25 - list.length)) }, () => (
429 <Text dimColor>· </Text>
430 ))}
431 </Text>
432 <Text dimColor>{isWide ? spaced('Working now') : 'WORKING NOW'}</Text>
433 {working.slice(0, 10).map(x => (
434 <Text>
435 <Text color="blue">{bar(Math.min(0.95, (now - x.startedAt) / 60000), 10)}</Text> {x.label.slice(0, 32)} <Text dimColor>{clock(now - x.startedAt)}</Text>
436 </Text>
437 ))}
438 </Box>
439 )}
440 </Box>
441 )
442 })
443
444 // ---- Roadtrip ----
445 on('ui.render', { component: 'Pane', requestId: 'roadtrip' }, async ($, e) => {
446 const { Box, Text, Button } = $.ui.resolve(e)
447 const g = await read($, game)
448 const lines: string[] = []
449 for (let y = 0; y < ROWS; y++) {
450 let row = '│'
451 for (let l = 0; l < LANES; l++) {
452 const car = y === ROWS - 1 && l === g.x
453 const rock = g.obstacles.some(o => o.y === y && o.lane === l)
454 row += car ? ' 🚗 ' : rock ? ' ▲▲ ' : ' '
455 }
456 lines.push(row + '│')
457 }
458 const steer = (d: number) => update($, game, c => (c.isRunning ? { ...c, x: Math.max(0, Math.min(LANES - 1, c.x + d)) } : c))
459 return (
460 <Box flexDirection="column" paddingX={1}>
461 <Text bold color={ORANGE}>◆ {spaced('Roadtrip')} <Text dimColor>score {g.score} · best {g.best}</Text></Text>
462 {lines.map(l => <Text>{l}</Text>)}
463 {g.isOver ? <Text color="red">Crash! Press start to retry.</Text> : null}
464 <Box>
465 <Button key="l" label="◀ a" hotkey="a" onPress={() => steer(-1)} />
466 <Button key="r" label="d ▶" hotkey="d" onPress={() => steer(1)} />
467 <Button key="s" label={g.isRunning ? 'Restart (s)' : 'Start (s)'} hotkey="s" variant="primary" onPress={() => update($, game, c => ({ ...FRESH, best: c.best, isRunning: true }))} />
468 </Box>
469 </Box>
470 )
471 })
472}
473types/index.d.ts 40 lines1export type Obstacle = { lane: number; y: number }
2export type Game = {
3 isRunning: boolean
4 isOver: boolean
5 x: number
6 score: number
7 best: number
8 obstacles: Obstacle[]
9}
10export type AgentRow = { id: string; label: string; status: string; startedAt: number }
11export type Step = { text: string; status: 'done' | 'active' | 'next' }
12export type Run = {
13 phase: 'idle' | 'working'
14 prompt: string
15 startedAt: number
16 tools: number
17 lastTool: string
18 steps: Step[]
19}
20export type Meter = { ctx: number; five: number; now: number }
21export type Seen = { model: string; effort: string }
22
23declare module 'claude-code' {
24 interface PluginState {
25 'tristens-tools': {
26 isClean: boolean
27 isUpgrade: boolean
28 model: string
29 effort: string
30 helper: string
31 team: string
32 run: Run
33 game: Game
34 agents: AgentRow[]
35 meter: Meter
36 seen: Seen
37 }
38 }
39}
40