SLOPSHOPPER

box-tools

A Tools button at the right end of the prompt footer that opens a small box to switch the model, the effort and your daymods on or off.

newpanespinnercommandprocesstimer
★ 1v0.1.1MITupdated 2026-10-15daydeda/daymods/mods/box-tools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · box-tools
│ ┃ Tools ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ Tools … · … │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭──────────────────────────────────────────╮ ⎿ Read 6 lines │ ┃ │ ◆ Model 1-5 │ ⏺ Update(src/auth.ts) │ ┃ │ Not available here (use /model). │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⚡ Effort q w e r t ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ q: effort-loww: effort-mediume: effort-hig │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ╭──────────────────────────────────────────╮ › /tools │ ┃ │ ❖ Mods a s d … │ ⎿ box-tools: Tools box open. Esc or a click outside closes it. │ ┃ │ No other daymods installed. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ │ ┃ ✓ Couldn't read your installed plugins. │ ┃ │ ┃ x: Close Esc or click away to close │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits tools

Draws

Pane · Tools
✦ Tools … · … ╭──────────────────────────────────────────────────────────╮ │ ◆ Model 1-5 │ │ Not available here (use /model). │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ ⚡ Effort q w e r t │ q: effort-loww: effort-mediume: effort-highr: effort-xhigh ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ ❖ Mods a s d … │ │ No other daymods installed. │ ╰──────────────────────────────────────────────────────────╯ ✓ Couldn't read your installed plugins. x: Close Esc or click away to close
README

<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-34d399?style=flat-square"></a> <img alt="Claude Code 2.1.300+" src="https://img.shields.io/badge/Claude%20Code-2.1.300%2B-818cf8?style=flat-square"> <img alt="mods" src="https://img.shields.io/badge/mods-4-f472b6?style=flat-square">

A collection of small mods for Claude Code. Each mod is its own plugin, so you install only the ones you want.

Mods use Claude Code's hooks-module API, which is early access and may change between releases. They were built and tested on Claude Code 2.1.300.

🧩 Mods

ModWhat it does
autoroutePicks the model and effort for each prompt, sends subagents to cheaper models, and keeps Fable and extra-usage spend in check.
prompt-upgradeCatches vague prompts, asks one quick multiple-choice question, rewrites the prompt with a working method, and lets you review it before it runs.
box-toolsA filled ✦ Tools button at the right end of the prompt footer. Click it for a small box where you switch the model, the effort, and each of your daymods on or off.
pace-statuslineA 4-line status line with limit bars, reset times and a pace prediction (⚠ runs out today 4:19pm) so you know before you hit the wall. Lives in its own repo.

More mods will be added here as they are ready.

📦 Install

Add the marketplace once, then install any mod from it.

In a Claude Code session:

/plugin install autoroute --marketplace daydeda/daymods

Or from a shell:

claude plugin marketplace add daydeda/daymods
claude plugin install autoroute@daymods
claude plugin install prompt-upgrade@daymods
claude plugin install box-tools@daymods
claude plugin install pace-statusline@daymods

Answer y when asked to add the marketplace and pick a scope. The mod is active right away. Mods installed at the user scope load in every later session.

To remove one: claude plugin uninstall autoroute.

🧭 autoroute

Routes each prompt to a suitable model and effort, so small jobs stop burning Opus tokens and big jobs get a plan.

How it works

  1. Pick a route per prompt. When you send a message, quick keyword and length rules choose a tier. If the rules cannot tell, a low-effort Haiku call decides. The choice is locked for the whole reply, so the prompt cache stays warm instead of being rebuilt on every step.
TierTypical promptsModelEffort
triviala question, typo, rename, quick lookupHaikulow
normala feature or fix in 1-3 filesSonnetmedium
hardtricky debugging, design, refactorsOpushigh
largemigrations, rewrites, architectureOpushigh, plus a delegation plan

Short follow-ups like "continue" or "yes" keep the previous route.

  1. Delegate large work. For the large tier, autoroute tells the model to work as a pipeline: Explore subagents map the code, a plan is written to a file, then a Sonnet subagent implements it. Other tiers are told to work directly.
  1. Route subagents. Explore agents run on Haiku at low effort, Plan agents on Opus, and any other subagent with no model set runs on Sonnet.
flowchart LR
  A([your prompt]) --> B{rules}
  B -->|clear| D[tier]
  B -->|unclear| C[Haiku classifier] --> D
  D --> E{plan and spend gate}
  E -->|Fable, not Max| F[Opus]
  E -->|limit near, extra usage on| G[Sonnet cap]
  E -->|ok| H[chosen model]

Two spend rules

These apply to every request, whatever chose its model, and stay on even when routing is turned off.

  • Fable needs a Max plan. autoroute reads your plan from the local Claude login record (~/.claude.json). Anything other than a Max plan, or a plan it cannot read, is downgraded from Fable to Opus. This covers the router, !fable, Fable picked in /model, and Fable subagents.
  • Spend guard. If extra usage is switched on for your account and any limit window (5-hour or weekly) passes the threshold (90% by default), routes are capped at Sonnet and a warning shows in the status line.

The plan check reads a local file the user can edit. It prevents accidental charges; it is not a security boundary.

Controls

  • Status line: by default autoroute adds no row of its own. It writes the current route to ~/.claude/autoroute.state, and a status line script can show it inline. pace-statusline does: Opus ▶ high ⇢ Haiku·low ▶ ⌂ daymods, plus a ⚠ note for downgrades or spend warnings. The ⇢ part is hidden once the running model matches the route. Set display to line to get autoroute's own row instead, for example autoroute: Haiku · low · "typo" (rules).
  • Compact status line: with pace-statusline, STATUSLINE_COMPACT=1 folds ctx, 5-hour, weekly and session stats into one row with short bars. The reset countdown shows only past 50% used.
  • Override one prompt: start a message with !haiku, !sonnet, !opus, !fable or !max. !auto routes normally.
  • /route on|off|status: turn routing on or off, or show the last route, your plan, and the spend guard state.
  • Options (in /config):
  • classifier: rules+haiku (default) or rules only. Rules-only makes no model call.
  • display: statusline (default) writes the route for your status line to show; line shows autoroute's own row.
  • spendGuardPercent: threshold for the spend guard, default 90.

Notes and limits

  • The keyword rules are a starting point and will misjudge some prompts. Use ! overrides, or /route status to see why a route was chosen.
  • The model IDs are set in mods/autoroute/hooks/route.ts. If your Claude Code build uses different IDs, change them there.
  • The Haiku classifier sends the start of your prompt (up to 4000 characters) to Haiku through your own Claude session. Nothing goes to any other service.

✨ prompt-upgrade

Turns a vague prompt like "make a todo app" into a clear one before Claude starts, and lets you check it first.

How it works

  1. Spot vague prompts. Quick local rules let most prompts straight through: follow-ups like "yes" or "continue", slash commands, questions, and anything that names a file, function, error or URL. Short, broad prompts ("make it better", "build a website") count as vague. Borderline ones get a quick Haiku check, which can also decide the prompt is already clear. "fix it" right after an error is left alone, because the conversation already says what "it" is.
  2. Ask a few quick questions. Haiku drafts as few as will do: one when one thing is open, up to three for a very vague prompt. Each is about a specific open decision in your prompt (where it runs, what it saves, how big), and one may check its reading: "Do you mean something like this: …?". They appear one at a time in a side panel (see below). For each, pick a numbered choice, pick Type my own answer… (a wrapped text box appears under it), or Skip. Press Esc to send your prompt untouched.
  3. Rewrite. Sonnet rewrites the prompt with your answer. It keeps your intent and adds the goal, where in the project, constraints and how to check it's done. It doesn't invent requirements: anything still open is left for Claude to pick a sensible default and say which.
  4. Review. The panel shows the rewritten prompt and its method:
  5. Run it: sends the rewrite.
  6. Edit it myself: puts the rewrite in the prompt box for you to change and send.
  7. Use my original: sends what you typed, unchanged.
  8. Cancel: sends nothing, and your text goes back in the prompt box.
  9. Ask for a change…: type what to change ("use Go", "smaller scope") in the text box under it, and it rewrites again.

The panel opens beside the transcript (or above the prompt). A step bar at the top shows where you are: ✓ Your prompt → ● Questions 2/3 → ○ Rewrite → ○ Review. Keys:

  • Questions: 1-3 pick a choice, the next number types your own answer, s skips the question, x skips the rest and rewrites now, b goes back to the previous question (your earlier answer is marked ✓).
  • Review: 1-4 as above, 5 asks for a change, b goes back to the questions with your answers kept, then rewrites again.
  • Always: Esc sends your prompt as typed. Back, Skip the rest and Esc sit together in a bar at the bottom of the panel.

Each question is its own card (QUESTION 2 OF 3, one dot per question). The panel uses Claude's orange for what is current and green for what is done, and opens at about 60% of the terminal width once you have run /refine once (100 columns before that).

Typing your own answer. Claude Code's text field is one line and cuts long text off, so the panel draws its own wrapped text box instead. Press the typing key (or click the box), type, and press Enter. It supports typing, Backspace and Ctrl+U to clear; there is no cursor movement. It is not available in the VS Code and mobile layouts, which only get the numbered choices and Skip.

The panel needs a terminal about 110 columns wide; on a narrower one it falls back to Claude Code's question dialog, with the rewrite shown inside the question. Set ui to dialog to always use the dialog.

Methods

Each rewrite picks one working method and writes its step into the prompt:

MethodIdeaWhat the prompt asks for
Directprompt engineeringA clear spec and nothing else, for small well-defined changes.
Context firstcontext engineeringRead specific code, docs or data before deciding.
Plan firstplan, then buildA short plan and a pause for your OK before editing, for big or open-ended work.
Verify looploop engineeringA named check (tests, build, lint) and iterating until it passes, stopping after 3 failed tries.
Make it a ruleharness engineeringDo the task now, then suggest making it permanent as a CLAUDE.md rule, hook, skill or /loop.

Controls

  • /refine <prompt>: upgrade one prompt on demand, whatever the mode.
  • /refine auto|always|off: when it steps in. auto (default) handles vague prompts only, always every new task you type, off only /refine <prompt>.
  • /refine status: the mode, and why the last prompt was or wasn't upgraded.
  • Options (in /config): mode, detector (rules+haiku or rules alone), ui (panel or dialog), and rewriteModel (sonnet by default, or haiku or opus).

Notes and limits

  • It only steps in on prompts you type while Claude is idle. Prompts with images attached, prompts typed mid-turn, and prompts from other plugins go through untouched.
  • A leading !opus-style autoroute override is kept in front of the rewritten prompt.
  • An upgrade costs one small Haiku call, plus one Sonnet call if it rewrites, and adds a few seconds before the turn starts. The calls go through your own Claude session and send the prompt, the names of the top-level files in your project and the last few messages. Nothing goes to any other service.

🧰 box-tools

A filled orange ✦ Tools pill sits at the right end of the prompt footer. Click it and a small box opens beside the transcript:

✦ Tools                                       Opus · high
╭─────────────────────────────────────────────────────────╮
│ ◆  Model                                            1-5 │
│ 1: ○ Default 2: ○ Haiku 3: ○ Sonnet 4: ● Opus 5: ○ Fable │
╰─────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────╮
│ ⚡ Effort                                    q w e r t │
│ q: ○ low  w: ○ medium  e: ● high  r: ○ xhigh  t: ○ max  │
╰─────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────╮
│ ❖  Mods                                         a s d … │
│ a: ● on   autoroute                                     │
╰─────────────────────────────────────────────────────────╯

Controls

  • Open: click ✦ Tools, or run /tools. Clicking the button again closes the box.
  • Pick: click a choice, press its hotkey, or Tab to it and press Enter. Hotkeys: 1-5 for models, q w e r t for effort, a s d… for mods.
  • Close: Esc, a click anywhere outside the box (the transcript or the prompt), x, or the button again.

What each control does

  • Model sets the model row of /config, the same as picking it there. It becomes your default for new sessions too.
  • Effort runs the built-in /effort <level>, as if you typed it. Claude Code saves the level for the current model, so it carries over to new sessions.
  • Mods lists the daymods you have installed (Box Tools itself is left out). A toggle runs claude plugin enable or claude plugin disable, then /reload-plugins, so the change applies right away and stays on or off in later sessions.

Notes and limits

  • When autoroute is on, it picks the model and effort for each prompt and overrides what you pick here. The box says so. Turn autoroute off in the same box to keep your pick.
  • A change made while Claude is working waits until the turn ends, the same as a typed command.
  • Mods loaded with --plugin-dir aren't installed, so they don't appear in the list.
  • Options (in /config): button (show or hide). With hide, the footer is left alone and /tools opens the box.

🛠 Development

Each mod lives in mods/<name>/ with the standard layout:

mods/<name>/
├── .claude-plugin/plugin.json
├── hooks/hooks.json
├── hooks/register.ts
└── tests/

Check and test a mod with:

claude plugin validate mods/autoroute
claude plugin test mods/autoroute

Try one without installing it: claude --plugin-dir mods/autoroute.

📄 License

MIT

Source 3 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, Timer } from 'claude-code'
3
4import { EFFORTS, EFFORT_KEYS, MODEL_KEYS, MOD_KEYS, firstLine, modelChoices, parseMods, title } from './tools'
5import type { View } from '../types'
6
7const PANE = 'box-tools'
8const FOCUS_POLL_MS = 250
9
10let showButton = true
11
12const EMPTY: View = { model: '', models: [], effort: '', mods: [], note: '', busy: '' }
13const view = atom({ plugin: 'box-tools', key: 'view' } as const, EMPTY)
14
15// The model as /config holds it, and the plain choices it offers.
16async function readModel($: EngineInterface): Promise<Pick<View, 'model' | 'models'>> {
17  try {
18    const row = (await $.config.list()).find(r => r.key === 'model')
19    if (!row || row.isLocked) return { model: '', models: [] }
20    return { model: String(row.value), models: modelChoices(row.options) }
21  } catch {
22    return { model: '', models: [] }
23  }
24}
25
26async function readMods($: EngineInterface) {
27  try {
28    const { exitCode, stdout } = await $.process.run(['claude', 'plugin', 'list', '--json'])
29    return exitCode === 0 ? parseMods(stdout) : undefined
30  } catch {
31    return undefined
32  }
33}
34
35// /effort saves the level per model (`modelSettings`), over the top-level default.
36async function readEffort($: EngineInterface): Promise<string> {
37  try {
38    const [settings, modelId] = await Promise.all([$.settings.read(), $.session.model()])
39    const perModel = (settings as { modelSettings?: Record<string, { effortLevel?: unknown }> }).modelSettings
40    const level = perModel?.[modelId]?.effortLevel ?? settings.effortLevel
41    return typeof level === 'string' ? level : ''
42  } catch {
43    return ''
44  }
45}
46
47async function refresh($: EngineInterface) {
48  const [model, mods, effort] = await Promise.all([readModel($), readMods($), readEffort($)])
49  await update($, view, (v): View => ({
50    ...v,
51    ...model,
52    effort: effort || v.effort,
53    mods: mods ?? v.mods,
54    note: mods === undefined ? "Couldn't read your installed plugins." : v.note,
55  }))
56}
57
58// Effort colors follow the theme, cool to hot, like the status line's.
59const EFFORT_COLORS: Record<string, string> = { low: 'success', medium: 'suggestion', high: 'warning', xhigh: 'error', max: 'error' }
60
61const PANE_ARGS = { id: PANE, title: 'Tools', focus: true, closeOnEscape: true, columns: 74, rows: 24 } as const
62
63// Set while a change runs, and for a moment after: the command it runs takes the keyboard,
64// sometimes just after it has finished, and that is not a click away.
65let isChanging = false
66let graceUntil = 0
67const GRACE_MS = 1000
68
69// One change at a time: a press while another runs is ignored.
70async function run($: EngineInterface, busy: string, work: () => Promise<Partial<View>>) {
71  if (isChanging || (await read($, view)).busy) return
72  isChanging = true
73  try {
74    await update($, view, (v): View => ({ ...v, busy, note: '' }))
75    const changed = await work().catch((err: unknown): Partial<View> => ({ note: `Failed: ${firstLine(String(err))}` }))
76    await update($, view, (v): View => ({ ...v, ...changed, busy: '' }))
77  } finally {
78    isChanging = false
79    graceUntil = (await $.clock.now()) + GRACE_MS
80  }
81  await takeKeyboard($)
82}
83
84// A command run from the box (/effort, /reload-plugins) hands the keyboard to the prompt, and an
85// open pane can't ask for it again: open the box afresh, which takes it over the empty prompt.
86async function takeKeyboard($: EngineInterface) {
87  const pane = (await $.ui.panes()).find(p => p.id === PANE)
88  if (!pane || pane.isFocused) return
89  await $.ui.close({ id: PANE }).catch(() => undefined)
90  const opened = await $.ui.open(PANE_ARGS).catch(() => undefined)
91  if (opened?.isPlaced) watchFocus($)
92}
93
94const setModel = async ($: EngineInterface, model: string) => {
95  if ((await read($, view)).model === model) return void (await update($, view, (v): View => ({ ...v, note: `Already on ${title(model)}.` })))
96  return run($, `Switching to ${title(model)}…`, async () => {
97    const r = await $.config.set({ key: 'model', value: model })
98    if ('deny' in r && r.deny) return { note: `Model not changed: ${r.deny}` }
99    // Effort is kept per model: show the new model's.
100    const effort = await readEffort($)
101    return { model, ...(effort ? { effort } : {}), note: `Model set to ${title(model)}.` }
102  })
103}
104
105const setEffort = async ($: EngineInterface, effort: string) => {
106  if ((await read($, view)).effort === effort) return void (await update($, view, (v): View => ({ ...v, note: `Effort is already ${effort}.` })))
107  return run($, `Setting effort to ${effort}…`, async () => {
108    // The built-in /effort, as if typed: it waits until Claude is idle.
109    const r = await $.command.run({ command: 'effort', args: effort })
110    const said = firstLine('text' in r ? r.text : undefined).split(':')[0] ?? ''
111    return { effort, note: said ? `${said}.` : `Effort set to ${effort}.` }
112  })
113}
114
115const toggleMod = ($: EngineInterface, id: string, name: string, on: boolean) =>
116  run($, `Turning ${name} ${on ? 'on' : 'off'}…`, async () => {
117    const r = await $.process.run(['claude', 'plugin', on ? 'enable' : 'disable', id])
118    if (r.exitCode !== 0) return { note: `${name} not changed: ${firstLine(r.stderr || r.stdout) || `exit ${r.exitCode}`}` }
119    await update($, view, (cur): View => ({
120      ...cur,
121      mods: cur.mods.map(m => (m.id === id ? { ...m, isEnabled: on } : m)),
122      note: `${name} ${on ? 'on' : 'off'}. Reloading plugins…`,
123      busy: '',
124    }))
125    // Reloads this mod too: the box stays open and its state is kept.
126    void $.command.run({ command: 'reload-plugins' }).catch(() => undefined)
127    return {}
128  })
129
130// Click-away: once the box has had the keyboard, losing it (a click in the transcript or the prompt) closes it.
131let watch: Timer | undefined
132let hadFocus = false
133
134function stopWatch() {
135  watch?.cancel()
136  watch = undefined
137}
138
139function watchFocus($: EngineInterface) {
140  stopWatch()
141  hadFocus = false
142  watch = $.clock.every(FOCUS_POLL_MS, () => {
143    void (async () => {
144      if (isChanging) return
145      const pane = (await $.ui.panes()).find(p => p.id === PANE)
146      if (!pane) return stopWatch()
147      if (pane.isFocused) hadFocus = true
148      else if ((await $.clock.now()) < graceUntil) await takeKeyboard($)
149      else if (hadFocus) {
150        stopWatch()
151        await $.ui.close({ id: PANE }).catch(() => undefined)
152      }
153    })()
154  })
155}
156
157async function isOpen($: EngineInterface) {
158  return (await $.ui.panes()).some(p => p.id === PANE)
159}
160
161// The footer button and /tools both toggle the box.
162async function toggle($: EngineInterface): Promise<boolean> {
163  if (await isOpen($)) {
164    stopWatch()
165    await $.ui.close({ id: PANE }).catch(() => undefined)
166    return false
167  }
168  await update($, view, (v): View => ({ ...v, note: '' }))
169  void refresh($)
170  const opened = await $.ui.open(PANE_ARGS)
171  if (opened.isPlaced) watchFocus($)
172  return opened.isPlaced
173}
174
175export const register: Register = (on, options) => {
176  showButton = options.button !== 'hide'
177
178  on('session.start', async ($, e, next) => {
179    await $.command.register({ name: 'tools', description: 'box-tools: open the Tools box (model, effort, mods)' })
180    // A reload with the box open (after a mod toggle) picks the fresh plugin list up
181    // and takes the keyboard back once /reload-plugins has finished with it.
182    if (await isOpen($)) {
183      void refresh($)
184      graceUntil = (await $.clock.now()) + GRACE_MS
185      watchFocus($)
186      $.clock.after(300, () => void takeKeyboard($))
187    }
188    return next(e)
189  })
190
191  on('command.run', { command: 'tools' }, async $ => {
192    const isShown = await toggle($)
193    return { text: isShown ? 'Tools box open. Esc or a click outside closes it.' : 'Tools box closed.' }
194  })
195
196  on('ui.close', { id: PANE }, async ($, e, next) => {
197    stopWatch()
198    return next(e)
199  })
200
201  // The right end of the prompt footer: the engine's mode labels, then the Tools button.
202  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
203    if (!showButton) return next(e)
204    const { Box, Text, Button } = $.ui.resolve(e)
205    const modes = e.props.modes
206    return (
207      <Box flexDirection="row" gap={1}>
208        {modes.length > 0 && <Text dimColor>{modes.join(' & ')}</Text>}
209        <Button key="tools" plain onPress={() => void toggle($)}>
210          <Text bold inverse color="claude">{' ✦ Tools '}</Text>
211        </Button>
212      </Box>
213    )
214  })
215
216  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
217    const { Box, Text, Button } = $.ui.resolve(e)
218    const v = await read($, view)
219    const autoroute = v.mods.find(m => m.name === 'autoroute')
220    const isFailure = /^(Failed|Couldn)|not changed|not available/i.test(v.note)
221
222    // A chip is one choice: the current one is a filled pill in the section's color, the rest sit dim.
223    const chip = (key: string, hotkey: string | undefined, label: string, isCurrent: boolean, color: string, onPress: () => void) => (
224      <Button key={key} hotkey={hotkey} plain dimColor={!isCurrent} onPress={onPress}>
225        {isCurrent
226          ? <Text bold inverse color={color}>{` ● ${label} `}</Text>
227          : <Text>{` ○ ${label} `}</Text>}
228      </Button>
229    )
230
231    // A titled card: a rounded border in the section's color, the title on top.
232    const card = (icon: string, name: string, color: string, hint: string, children: RenderChildren) => (
233      <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
234        <Box flexDirection="row" justifyContent="space-between">
235          <Text bold color={color}>{`${icon}  ${name}`}</Text>
236          <Text dimColor>{hint}</Text>
237        </Box>
238        {children}
239      </Box>
240    )
241
242    const modelName = v.model ? title(v.model) : '…'
243    return (
244      <Box flexDirection="column" gap={1}>
245        <Box flexDirection="row" justifyContent="space-between">
246          <Text bold color="claude">{'✦ Tools'}</Text>
247          <Text dimColor>{`${modelName} · ${v.effort || '…'}`}</Text>
248        </Box>
249        {card('◆', 'Model', 'claude', '1-5', (
250          <Box flexDirection="row" flexWrap="wrap">
251            {v.models.length === 0 && <Text dimColor>Not available here (use /model).</Text>}
252            {v.models.map((m, i) => chip(`model-${m}`, MODEL_KEYS[i], title(m), m === v.model, 'claude', () => void setModel($, m)))}
253          </Box>
254        ))}
255        {card('⚡', 'Effort', 'warning', 'q w e r t', (
256          <Box flexDirection="row" flexWrap="wrap">
257            {EFFORTS.map((level, i) => chip(`effort-${level}`, EFFORT_KEYS[i], level, level === v.effort, EFFORT_COLORS[level] ?? 'warning', () => void setEffort($, level)))}
258          </Box>
259        ))}
260        {card('❖', 'Mods', 'success', 'a s d …', (
261          <Box flexDirection="column">
262            {v.mods.length === 0 && <Text dimColor>No other daymods installed.</Text>}
263            {v.mods.map((m, i) => (
264              <Button key={`mod-${m.name}`} hotkey={MOD_KEYS[i]} plain dimColor={!m.isEnabled} onPress={() => void toggleMod($, m.id, m.name, !m.isEnabled)}>
265                <Text bold={m.isEnabled} color={m.isEnabled ? 'success' : undefined}>{`${m.isEnabled ? '● on ' : '○ off'}  ${m.name}`}</Text>
266              </Button>
267            ))}
268          </Box>
269        ))}
270        {autoroute?.isEnabled && <Text color="warning" dimColor>{'⚠ autoroute is on: it picks the model and effort per prompt, over these.'}</Text>}
271        {(v.busy || v.note) && <Text color={v.busy ? 'suggestion' : isFailure ? 'error' : 'success'}>{v.busy ? `⏳ ${v.busy}` : `✓ ${v.note}`}</Text>}
272        <Box flexDirection="row" columnGap={2}>
273          <Button key="close" hotkey="x" plain dimColor onPress={() => void $.ui.close({ id: PANE }).catch(() => undefined)}>Close</Button>
274          <Text dimColor>Esc or click away to close</Text>
275        </Box>
276      </Box>
277    )
278  })
279}
280
hooks/tools.ts 52 lines
1// Pure helpers: no `$`, so they can be read and tested on their own.
2
3import type { Mod } from '../types'
4
5export const SELF = 'box-tools'
6export const MARKETPLACE = 'daymods'
7
8// The plain model choices, in the order the box shows them; `/config` may offer more.
9const MODEL_CHOICES = ['default', 'haiku', 'sonnet', 'opus', 'fable'] as const
10export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
11
12// Hotkeys inside the box: digits for models, the qwert row for effort, asdfg… for mods.
13export const MODEL_KEYS = ['1', '2', '3', '4', '5']
14export const EFFORT_KEYS = ['q', 'w', 'e', 'r', 't']
15export const MOD_KEYS = ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l']
16
17export function modelChoices(options: readonly string[] | undefined): string[] {
18  if (!options) return []
19  return MODEL_CHOICES.filter(m => options.includes(m))
20}
21
22export const title = (s: string) => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
23
24/**
25 * The installed daymods, from `claude plugin list --json`. Box Tools itself is left out:
26 * turning it off from its own box would take the box away.
27 */
28export function parseMods(json: string): Mod[] | undefined {
29  let list: unknown
30  try {
31    list = JSON.parse(json)
32  } catch {
33    return undefined
34  }
35  if (!Array.isArray(list)) return undefined
36  const mods: Mod[] = []
37  for (const p of list) {
38    if (!p || typeof p !== 'object') continue
39    const { id, enabled } = p as { id?: unknown; enabled?: unknown }
40    if (typeof id !== 'string') continue
41    const [name, marketplace] = id.split('@')
42    if (marketplace !== MARKETPLACE || !name || name === SELF) continue
43    mods.push({ id, name, isEnabled: enabled === true })
44  }
45  return mods.sort((a, b) => a.name.localeCompare(b.name))
46}
47
48// The first line of a command's output, for the note under the controls.
49export function firstLine(text: string | undefined): string {
50  return (text ?? '').split('\n').map(l => l.trim()).find(Boolean) ?? ''
51}
52
types/index.d.ts 20 lines
1export type Mod = { id: string; name: string; isEnabled: boolean }
2
3// What the Tools box shows.
4export type View = {
5  model: string
6  models: string[]
7  effort: string
8  mods: Mod[]
9  // One line under the controls: what the last change did, or why it didn't.
10  note: string
11  // Set while a change is running, so a second press waits.
12  busy: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'box-tools': { view: View }
18  }
19}
20