SLOPSHOPPER

tristens-tools

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

newpanebandrowsguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tristens-tools
│ ┃ Tristen's Tools ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ TRISTEN'S TOOLS Opus 5.5 · Medium │ ┃ MODEL Haiku Sonnet Opus Fable ⏺ Read(src/auth.ts) │ ┃ EFFORT Low Med High XHigh Max ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ S E T T I N G S ⎿ Added 2 lines, removed 1 line │ ┃ ● Clean View [ ○ Off ] ⏺ Bash(bun test) │ ┃ ● Prompt Upgrade [ ○ Off ] ⎿ 3 pass, 1 fail │ ┃ ● Helper agents [ Fast & Cheap ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ L A U N C H │ ┃ ✦ Create photo or video ✻ Worked for 42s · done 4:20 PM │ ┃ ◆ Agent Dock │ ┃ ▶ Roadtrip › /tools │ ⎿ tristens-tools: Tristen's Tools opened. │ │ ⟨Claude Code's own drawing⟩ ◆ Subscription │ ◆ Opus 5.5 │ effort medium │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% │ 5h 31% │ ◇ 0 agents [ ◆ Tristen's Tools ▾ ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ◆ Subscription │ ◆ Opus 5.5 │ effort medium │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% │ 5h 31% │ ◇ 0 agents[ ◆ Tristen's
Pane · Tristen's Tools
◆ TRISTEN'S TOOLS Opus 5.5 · Medium MODEL Haiku Sonnet Opus Fable EFFORT Low Med High XHigh Max S E T T I N G S ● Clean View [ ○ Off ] ● Prompt Upgrade [ ○ Off ] ● Helper agents [ Fast & Cheap ] L A U N C H ✦ Create photo or video ◆ Agent Dock ▶ Roadtrip
Pane · Agent Dock
◆ AGENT DOCK STANDING BY TEAM Off 1 3 5 10 20 50 100 Pick a team size; it applies to every request until set to Off. ╭────────────────────────────────────────────────────────╮ │ │ │ ● │ │ No team selected │ │ Requests run on a single agent. │ │ │ ╰────────────────────────────────────────────────────────╯
Pane · Roadtrip
◆ R O A D T R I P score 0 · best 0 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ 🚗 │ [ ◀ a ][ d ▶ ][ Start (s) ]
README

claude-code-mods

🧩 Claude Code Mods: Make Your Terminal Come Alive

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.

Claude Code TypeScript License: MIT

⚠️ 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.


⚡ Quick Start (30 seconds)

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/


🎮 What's Included

ModWhat it doesCommands
🐾 pixel-buddyA 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-toolsA 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-boxA 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
📊 statusbarA 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-modThe 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

📂 Repository Structure

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)

🎯 What You'll Learn

  • What a Claude Code mod is, and how it differs from skills, slash commands and classic shell hooks
  • How the on(event, hook) / next(e) middleware chain works
  • How to draw UI in the terminal: bands above the prompt, panes, buttons, even a pixel raster at 25 fps
  • How to keep state with atoms and have the UI redraw automatically
  • How to change Claude's behaviour: swap the model per request, add system-prompt sections, react to tool calls
  • How to test a mod with claude plugin test and ship it as a GitHub marketplace

🚦 Prerequisites

  • Claude Code 2.1.294 or newer in a terminal (claude --version)
  • A Claude subscription or API key (whatever you already use with Claude Code)
  • Git if you want to clone and hack on the mods
  • Coffee ☕ (highly recommended)

No Node, no npm install, no build step. Claude Code compiles the TypeScript itself.


🔨 Build Your Own Mod

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.


📁 Configure project-box

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.


🐛 Troubleshooting

ProblemFix
Nothing appears after installRun /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 foundCheck the repo name: Bocskay-Botond/claude-code-mods
A mod silently does nothingStart with claude --debug. A failing hook is skipped and logged with the reason.
Two bands fight for spacepixel-buddy, project-box and tristens-tools are designed to stack. Turn things off with /buddy, /projects, or /plugin → disable.

More: docs/troubleshooting.md


🤝 Contributing

Built a cool mod? PRs are welcome!

  1. Fork the repo
  2. Add your mod under mods/<your-mod>/ (four files, see above)
  3. Make sure claude plugin validate mods/<your-mod> and claude plugin test mods/<your-mod> pass
  4. Add it to .claude-plugin/marketplace.json and to the table in this README
  5. Open a PR with a screenshot or GIF 📸

📄 License

MIT. Do whatever you want, just don't blame me if your critter falls asleep on the job. 😴


🙏 Acknowledgments


☕ Support

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

Source 2 files
hooks/register.tsx 473 lines
1import { 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}
473
types/index.d.ts 40 lines
1export 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