SLOPSHOPPER

toolbar

A Toolbar button under the prompt: pick the model and effort, open the project in VS Code, and switch clean view, which hides the work and shows a step tracker…

newbandspinnerrowsguardcommand
v0.10.0MITupdated 2026-10-11rafagomes/claude-code-mods/toolbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · toolbar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /toolbar ⎿ toolbar: Toolbar opened. ╭────────────────────────────────────────────────────────────────╮ │ ◆ T O O L B A R Opus 5.5 │ │ │ │ MODEL Haiku 4.5 Sonnet 5.5 Opus 5.5 Fable 5.1 │ │ EFFORT Low Medium High XHigh Max │ │ │ │ SETTINGS │ │ Clean View task list and summary only [ ○ Off ] │ │ Project this project's root [ Open in VS Code ] │ │ │ │ claude-opus-5-5 [ Done ] │ ╰────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts [ ◆ Toolbar ▴ ]

Draws

Band
╭────────────────────────────────────────────────────────────────╮ │ ◆ T O O L B A R Opus 5.5 │ │ │ │ MODEL Haiku 4.5 Sonnet 5.5 Opus 5.5 Fable 5.1 │ │ EFFORT Low Medium High XHigh Max │ │ │ │ SETTINGS │ │ Clean View task list and summary only [ ○ Off ] │ │ Project this project's root [ Open in VS Code ] │ │ │ │ claude-opus-5-5 [ Done ] │ ╰────────────────────────────────────────────────────────────────╯
Prompt hint
? for shortcuts [ ◆ Toolbar ▴ ]
README

claude-code-mods

Mods for Claude Code: function-hook plugins that run inside the session and change what you see around the prompt. Each one installs separately — take only what you want.

Built and tested on Claude Code 2.1.296; mods need a version with function-hook plugins (2.1.275 or later). The status line is a plain script and only needs jq.

ModWhat it does
english-coachChecks the grammar and fluency of each prompt you write and shows corrections, plus a word or expression to learn, in a band above the prompt
toolbarA Toolbar button under the prompt: model and effort picker, open in VS Code, and a clean view that replaces the work log with a step tracker
statuslineA status line that shows where you are and what you are driving, and a rate-limit row only when a limit needs attention. A script, not a plugin — install it by hand

Install

In a session:

/plugin marketplace add rafagomes/claude-code-mods
/plugin install english-coach@claude-code-mods
/plugin install toolbar@claude-code-mods

Or from a shell:

claude plugin marketplace add rafagomes/claude-code-mods
claude plugin install english-coach@claude-code-mods
claude plugin install toolbar@claude-code-mods

Turn one off with /plugin disable <mod>@claude-code-mods; remove it with claude plugin uninstall <mod>@claude-code-mods. Plugins are installed per profile — set CLAUDE_CONFIG_DIR in front of the commands for each extra profile.

What each mod sends, and where

Both mods make extra model requests on your account. Read this before installing.

english-coach

Each prompt you write is checked for grammar and fluency — phrasing that is correct but not what a native speaker would say — by a parallel Haiku call, so the turn never waits. Corrections appear as colour-coded bullets ([grammar] / [fluency], original → improved, reason) in a dismissable band above the prompt, plus a "Natural:" rewrite when a short message (under 300 characters) has two or more issues. The last line is a "Learn:" tip — one word, phrasal verb or idiom a native speaker would have used in that message, with its meaning — so the band also appears, with only that line, when there is nothing to correct. A bulleted copy is written to the transcript as a log entry for you, not as part of the conversation.

  • Checked: prompts typed at the terminal or sent through Remote Control. Prompts from the SDK, background tasks, peers and schedules are not.
  • Sent: your own words from the prompt — pasted blocks and fenced code are stripped first — capped at 3,000 characters, one Haiku request per prompt.
  • Skipped entirely: slash commands, ! shell lines, and prompts with fewer than four of your own words left after stripping.
  • Switched off and on: /english-coach off stops the checks for the rest of the session and takes the band down; /english-coach on resumes them, and with no argument it toggles; anything else changes nothing. Every session starts with it on. With the toolbar mod installed, the same switch is a row in its card.
  • Tuned for Portuguese speakers: the coaching prompt describes the writer as a Brazilian software engineer and looks for literal translations and false friends from Portuguese. It works for anyone, but edit SYSTEM in english-coach/hooks/register.tsx to describe yourself.

toolbar

Adds a Toolbar button at the right of the hint row under the prompt (also /toolbar). It opens a card above the prompt to:

  • pick the model, the 1M-context variant and the effort — applied with one /model and one /effort when the card closes;
  • open the project root in VS Code (runs open -a "Visual Studio Code", so macOS only);
  • switch clean view;
  • switch the English coach on and off — a row drawn only when the english-coach mod is loaded, which runs its /english-coach command.

Clean view hides the work — tool rows, the spinner line, the turn-duration line, notifications of background tasks that completed — and shows a step tracker of the task list instead: a short heading, Step N of M with a progress bar, and one row per task. Of the model's text, only the answer to your last prompt stays drawn, plus the latest message of a turn a background task started.

  • Sent, only while clean view is on: each prompt you send (typed at the terminal, or through Remote Control or the SDK; up to 4,000 characters) goes to Haiku once for the band's 3-to-6-word heading. A turn started by a background task, a peer or a schedule is never sent.
  • Added to the system prompt, only while clean view is on: a short section telling the model that only its task list and final message are seen, and to keep both complete. The steps come from the model's own task tools.

Development

claude plugin test english-coach     # run a mod's tests
claude plugin test toolbar
claude --plugin-dir toolbar          # try a mod without installing it

To type-check a mod, load it once (installed, or with --plugin-dir): the engine writes its types and a tsconfig.json into the mod folder (both gitignored). Then run npx tsc -p <mod>.

After editing a mod installed from a local clone (claude plugin marketplace add /path/to/claude-code-mods), /reload-plugins picks up the change.

License

MIT

Source 3 files
hooks/register.tsx 457 lines
1import { atom, read, update } from 'claude-code'
2import type { CommandRunInput, EngineInterface, Register } from 'claude-code'
3
4import type { Toolbar } from '../types'
5
6import { EMPTY as CLEAN, mix, registerClean, wholeClean } from './clean'
7
8// The card's width in columns: the model row on one line, inside the border.
9const CARD = 66
10const MODELS = [
11  { name: 'haiku', label: 'Haiku 4.5' },
12  { name: 'sonnet', label: 'Sonnet 5.5' },
13  { name: 'opus', label: 'Opus 5.5' },
14  { name: 'fable', label: 'Fable 5.1' },
15]
16const EFFORTS = [
17  { name: 'low', label: 'Low' },
18  { name: 'medium', label: 'Medium' },
19  { name: 'high', label: 'High' },
20  { name: 'xhigh', label: 'XHigh' },
21  { name: 'max', label: 'Max' },
22]
23const LONG = '[1m]'
24
25const NONE: Toolbar['pick'] = { model: null, effort: null }
26const EMPTY: Toolbar = { model: '', effort: null, options: [], open: false, pick: NONE }
27const view = atom({ plugin: 'toolbar', key: 'view' } as const, EMPTY)
28
29// The session keeps this value across reloads, so one saved by an older
30// version of the mod may lack a field: every read and write starts from EMPTY.
31const whole = (saved: Partial<Toolbar> | undefined): Toolbar => ({
32  ...EMPTY,
33  ...saved,
34  open: saved?.open === true,
35})
36
37const load = async ($: EngineInterface) => whole(await read($, view))
38
39const change = ($: EngineInterface, fn: (one: Toolbar) => Toolbar) =>
40  update($, view, saved => fn(whole(saved)))
41
42const family = (model: string) => MODELS.find(one => model.toLowerCase().includes(one.name))
43const isLong = (model: string) => model.toLowerCase().includes(LONG)
44const isSameModel = (a: string, b: string) => family(a) === family(b) && isLong(a) === isLong(b)
45
46// "Opus 5.5 1M · Medium": what the session runs on, in the card's corner.
47const summary = (model: string, effort: string | null) =>
48  [
49    `${family(model)?.label ?? 'Model'}${isLong(model) ? ' 1M' : ''}`,
50    EFFORTS.find(one => one.name === effort)?.label ?? effort,
51  ]
52    .filter(Boolean)
53    .join(' · ')
54
55// Runs a built-in command as the person typing it would: its own output row
56// in the transcript is the confirmation. False when the engine refused it.
57const run = async ($: EngineInterface, command: string, args: string) => {
58  try {
59    await $.command.run({ command, args } as CommandRunInput)
60
61    return true
62  } catch {
63    $.ui.toast(`/${command} ${args} did not run`)
64
65    return false
66  }
67}
68
69const sync = async ($: EngineInterface) => {
70  const model = await $.session.model()
71  await change($, one => ({ ...one, model }))
72}
73
74const show = async ($: EngineInterface) => {
75  await sync($)
76  await change($, one => ({ ...one, open: true }))
77}
78
79const pick = ($: EngineInterface, picked: Partial<Toolbar['pick']>) =>
80  change($, one => ({ ...one, pick: { ...one.pick, ...picked } }))
81
82// Closing is what applies the picks: one /model and one /effort at most, and
83// neither when the pick is what the session already has.
84const close = async ($: EngineInterface) => {
85  const { model, effort, pick: picked } = await load($)
86  await change($, one => ({ ...one, open: false, pick: NONE }))
87
88  if (picked.model !== null && !isSameModel(picked.model, model) && (await run($, 'model', picked.model))) {
89    await sync($)
90  }
91
92  if (picked.effort !== null && picked.effort !== effort && (await run($, 'effort', picked.effort))) {
93    const level = picked.effort
94    await change($, one => ({ ...one, effort: level }))
95  }
96}
97
98// The same value ./clean keeps: the card switches it, the band draws its list.
99const clean = atom({ plugin: 'toolbar', key: 'clean' } as const, CLEAN)
100// The ticks ./clean counts while a turn works: where the loading bars are.
101const beat = atom({ plugin: 'toolbar', key: 'beat' } as const, 0)
102
103// The english-coach mod's own switch, read here and flipped by its command.
104// That mod writes it at session start: never written, it is not loaded, and
105// the card has no row for it.
106const coach = { plugin: 'english-coach', key: 'on' } as const
107
108const toggleClean = ($: EngineInterface) =>
109  update($, clean, saved => ({ ...wholeClean(saved), on: !wholeClean(saved).on }))
110
111// Opens the project's root in VS Code by the app's name, so it does not
112// depend on the `code` launcher being on the host's PATH.
113const openEditor = async ($: EngineInterface) => {
114  try {
115    const { exitCode } = await $.process.run(['open', '-a', 'Visual Studio Code', await $.session.root()])
116
117    if (exitCode === 0) {
118      return
119    }
120  } catch {
121    // The toast below says it.
122  }
123
124  $.ui.toast('VS Code did not open')
125}
126
127// The clean view band: its widest, the columns of a task's own bar, of the
128// lit run that crosses it while the task is in progress and of the word
129// after it, and the gap between columns.
130const BAND = 100
131const BAR = 20
132const RUN = 4
133const NOTE = 8
134const GAP = 2
135const CELL = '▉'
136const REST = '░'
137
138const WARM = ['#f6a04d', '#ec4899']
139const GREEN = ['#2f9e44', '#8ce99a']
140const HEADING = ['#f6a04d', '#f472b6', '#c084fc', '#7aa2f7']
141
142// The 1M-context variant of a model where this build has one, else the model.
143const sized = (name: string, wantsLong: boolean, options: string[]) =>
144  wantsLong && options.includes(`${name}${LONG}`) ? `${name}${LONG}` : name
145
146export const register: Register = (on, options) => {
147  registerClean(on, options)
148
149  on('session.start', async ($, e, next) => {
150    await sync($)
151
152    try {
153      const row = (await $.config.list()).find(one => one.key === 'model')
154      const saved = (await $.settings.read()) as { effortLevel?: unknown }
155      const effort = typeof saved.effortLevel === 'string' ? saved.effortLevel : null
156      await change($, one => ({
157        ...one,
158        options: [...(row?.options ?? [])],
159        effort: one.effort ?? effort,
160      }))
161    } catch {
162      // Without them the rows still draw: every model, no effort marked.
163    }
164
165    await $.command.register({
166      name: 'toolbar',
167      description: 'Open or close the toolbar card above the prompt (model, effort, clean view, VS Code)',
168      argumentHint: '[close]',
169    })
170
171    return next(e)
172  })
173
174  on('command.run', { command: 'toolbar' }, async ($, e) => {
175    const { open } = await load($)
176    const wantsOpen = e.args.trim() !== 'close' && !open
177
178    if (wantsOpen) {
179      await show($)
180    } else {
181      // A command cannot run another from inside its own hook: right after it.
182      $.clock.after(0, () => close($))
183    }
184
185    return { text: wantsOpen ? 'Toolbar opened.' : 'Toolbar closed.' }
186  })
187
188  // The effort the turn really ran at: a change made by /effort, or a model
189  // that downgraded it, shows here after the turn.
190  on('classic.Stop', async ($, e, next) => {
191    const level = e.effort?.level
192
193    if (level !== undefined) {
194      await change($, one => ({ ...one, effort: level }))
195    }
196
197    await sync($)
198
199    return next(e)
200  })
201
202  // The button that opens the card sits at the right end of the hint row
203  // under the prompt, beside the hint itself.
204  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
205    const beneath = await next(e)
206    const { Box, Button, Text } = $.ui.resolve(e)
207    const { open } = await load($)
208
209    return (
210      <Box justifyContent="space-between" width="100%">
211        {beneath.type === 'engine' ? (
212          <Text dimColor wrap="truncate-end">
213            {e.props.hint}
214            {e.props.tail ?? ''}
215          </Text>
216        ) : (
217          beneath
218        )}
219        <Button
220          key="toolbar"
221          label={open ? '◆ Toolbar ▴' : '◆ Toolbar ▾'}
222          variant={open ? 'primary' : 'secondary'}
223          onPress={() => (open ? close($) : show($))}
224        />
225      </Box>
226    )
227  })
228
229  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
230    // What the plugins beneath draw in the band stays, above the card.
231    const beneath = await next(e)
232    const one = await load($)
233
234    const view = wholeClean(await read($, clean))
235    const hasList = view.on && (view.working || view.tasks.length > 0)
236
237    if (e.props.hasSurvey || (!one.open && !hasList)) {
238      return beneath
239    }
240
241    const { Box, Button, Text } = $.ui.resolve(e)
242    const { value: coachOn } = await $.state.get(coach)
243    const { tasks } = view
244    const done = tasks.filter(task => task.status === 'completed').length
245    const upcoming = tasks.find(task => task.status === 'pending')
246    const share = tasks.length === 0 ? 0 : done / tasks.length
247    const step = `Step ${Math.min(done + 1, tasks.length)} of ${tasks.length}`
248    const inner = Math.min(BAND, e.props.bodyColumns ?? BAND)
249    const cells = inner >= 60 ? BAR : BAR / 2
250    const words = view.title === '' ? [] : view.title.split(' ')
251
252    const total = Math.max(cells, inner - step.length - NOTE - GAP * 2)
253
254    // The lit run of a task in progress goes to the end of its bar and back.
255    const span = cells - RUN
256    const lap = (await read($, beat)) % (span * 2)
257    const at = lap <= span ? lap : span * 2 - lap
258
259    // A bar with `lit` cells colored from `from` on: the gradient runs over
260    // `over` cells, the whole bar for one that fills and the run for one that
261    // only moves.
262    const bar = (key: string, from: number, lit: number, width: number, stops: readonly string[], over = width) => (
263      <Text key={key}>
264        <Text color="subtle">{REST.repeat(from)}</Text>
265        {Array.from({ length: lit }, (_, cell) => (
266          <Text key={`${key}-${cell}`} color={mix(stops, cell / Math.max(1, over - 1))}>
267            {CELL}
268          </Text>
269        ))}
270        <Text color="subtle">{REST.repeat(width - from - lit)}</Text>
271      </Text>
272    )
273
274    const { options } = one
275    // The card shows the picks; the session keeps its own until it closes.
276    const model = one.pick.model ?? one.model
277    const effort = one.pick.effort ?? one.effort
278    const isPending =
279      !isSameModel(model, one.model) || (one.pick.effort !== null && effort !== one.effort)
280    const picked = family(model)?.name
281    const long = isLong(model)
282    const models = MODELS.filter(({ name }) => options.length === 0 || options.includes(name))
283    const hasLong = options.some(name => name.endsWith(LONG))
284
285    return (
286      <Box flexDirection="column">
287        {beneath.type !== 'engine' && beneath}
288        {hasList && (
289          <Box flexDirection="column" width={inner}>
290            {words.length > 0 && (
291            <Text wrap="truncate-end">
292              <Text color={HEADING[0]}>✳ </Text>
293              {words.map((word, at) => (
294                <Text key={`word-${at}`} bold color={mix(HEADING, at / Math.max(1, words.length - 1))}>
295                  {`${word} `}
296                </Text>
297              ))}
298            </Text>
299            )}
300            {tasks.length === 0 ? (
301              <Text dimColor>working, no task list yet</Text>
302            ) : (
303              <Box columnGap={GAP}>
304                <Text>{step}</Text>
305                {bar('total', 0, Math.round(share * total), total, WARM)}
306              </Box>
307            )}
308            {tasks.map(task => (
309              <Box key={`task-${task.id}`} columnGap={GAP}>
310                <Box width={Math.max(1, inner - cells - NOTE - GAP * 2)}>
311                  {task.status === 'completed' ? (
312                    <Text color="success">✔ </Text>
313                  ) : task.status === 'in_progress' ? (
314                    <Text color={WARM[1]}>● </Text>
315                  ) : (
316                    <Text color="inactive">○ </Text>
317                  )}
318                  <Text
319                    bold={task.status === 'in_progress'}
320                    color={task.status === 'in_progress' ? undefined : 'inactive'}
321                    wrap="truncate-end"
322                  >
323                    {task.subject}
324                  </Text>
325                </Box>
326                {task.status === 'completed'
327                  ? bar(`bar-${task.id}`, 0, cells, cells, GREEN)
328                  : task.status === 'in_progress'
329                    ? bar(`bar-${task.id}`, at, RUN, cells, WARM, RUN)
330                    : bar(`bar-${task.id}`, 0, 0, cells, WARM)}
331                <Box width={NOTE}>
332                  {task.status === 'completed' ? (
333                    <Text>Done</Text>
334                  ) : task.status === 'in_progress' ? (
335                    <Text bold color={WARM[1]}>
336                      Working
337                    </Text>
338                  ) : (
339                    <Text color="inactive">
340                      {task === upcoming ? 'Next' : 'Up next'}
341                    </Text>
342                  )}
343                </Box>
344              </Box>
345            ))}
346          </Box>
347        )}
348        {one.open && (
349        <Box justifyContent="flex-end">
350          <Box
351            flexDirection="column"
352            borderStyle="round"
353            borderColor="subtle"
354            paddingX={1}
355            width={Math.min(CARD, e.props.bodyColumns ?? CARD)}
356          >
357            <Box justifyContent="space-between">
358              <Text bold color="claude">
359                ◆ T O O L B A R
360              </Text>
361              <Text dimColor>{summary(model, effort)}</Text>
362            </Box>
363            <Text> </Text>
364            <Box flexWrap="wrap" columnGap={2}>
365              <Text dimColor>MODEL </Text>
366              {models.map(({ name, label }) =>
367                name === picked ? (
368                  <Text key={`on-${name}`} bold color="inverseText" backgroundColor="claude">
369                    {` ${label} `}
370                  </Text>
371                ) : (
372                  <Button
373                    key={`model-${name}`}
374                    label={label}
375                    plain
376                    onPress={() => pick($, { model: sized(name, long, options) })}
377                  />
378                ),
379              )}
380              {hasLong && (
381                <Button
382                  key="model-1m"
383                  label={long ? '● 1M' : '○ 1M'}
384                  plain
385                  onPress={() => pick($, { model: sized(picked ?? 'opus', !long, options) })}
386                />
387              )}
388            </Box>
389            <Box flexWrap="wrap" columnGap={2}>
390              <Text dimColor>EFFORT</Text>
391              {EFFORTS.map(({ name, label }) =>
392                name === effort ? (
393                  <Text key={`on-${name}`} bold color="inverseText" backgroundColor="permission">
394                    {` ${label} `}
395                  </Text>
396                ) : (
397                  <Button
398                    key={`effort-${name}`}
399                    label={label}
400                    plain
401                    onPress={() => pick($, { effort: name })}
402                  />
403                ),
404              )}
405            </Box>
406            <Text> </Text>
407            <Text dimColor>SETTINGS</Text>
408            <Box justifyContent="space-between">
409              <Text>
410                Clean View <Text dimColor> task list and summary only</Text>
411              </Text>
412              <Button
413                key="clean"
414                label={view.on ? '● On' : '○ Off'}
415                variant={view.on ? 'primary' : 'secondary'}
416                onPress={() => toggleClean($)}
417              />
418            </Box>
419            {coachOn !== undefined && (
420              <Box justifyContent="space-between">
421                <Text>
422                  English Coach <Text dimColor> corrections and a word to learn</Text>
423                </Text>
424                <Button
425                  key="coach"
426                  label={coachOn ? '● On' : '○ Off'}
427                  variant={coachOn ? 'primary' : 'secondary'}
428                  onPress={() => run($, 'english-coach', coachOn ? 'off' : 'on')}
429                />
430              </Box>
431            )}
432            <Box justifyContent="space-between">
433              <Text>
434                Project <Text dimColor> this project's root</Text>
435              </Text>
436              <Button
437                key="editor"
438                label="Open in VS Code"
439                variant="secondary"
440                onPress={() => openEditor($)}
441              />
442            </Box>
443            <Text> </Text>
444            <Box justifyContent="space-between">
445              <Text dimColor>
446                {isPending ? 'applies when closed' : one.model === '' ? 'model unknown' : one.model}
447              </Text>
448              <Button key="done" label="Done" variant="primary" onPress={() => close($)} />
449            </Box>
450          </Box>
451        </Box>
452        )}
453      </Box>
454    )
455  })
456}
457
hooks/clean.tsx 326 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Clean } from '../types'
5
6// What the model is told while clean view is on: the task list and the final
7// message are all the person sees, so both have to carry the turn.
8const GUIDE = `# Clean view
9
10The person has turned on clean view: your tool calls, their results and the text you write between them are hidden. They see only your task list, with its progress, and the final message of your turn.
11
12- For any work of more than one step, create the task list before you start, one task per step, and keep every status current as you go: in progress when you begin a step, completed as soon as it is done.
13- End the turn with a summary that stands by itself: what was done, what was verified and how, what failed or was left out, and anything the person has to decide or run.
14- A question or a blocker goes in that final message too; nothing written earlier in the turn is read.`
15
16// The longest heading the band draws before it is cut with an ellipsis.
17const TITLE = 60
18
19export const EMPTY: Clean = { on: false, working: false, tasks: [], finals: [], title: '', turnId: '' }
20
21const headed = (text: string) => {
22  const line = (text.trim().split('\n')[0] ?? '').trim()
23
24  return line.length > TITLE ? `${line.slice(0, TITLE).trimEnd()}…` : line
25}
26
27// How often the loading bar of a task in progress moves one cell.
28const BEAT = 120
29
30const beat = atom({ plugin: 'toolbar', key: 'beat' } as const, 0)
31
32const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
33
34// The color a fraction of the way along a list of hex stops.
35export const mix = (stops: readonly string[], fraction: number) => {
36  const at = Math.min(Math.max(fraction, 0), 1) * (stops.length - 1)
37  const from = channels(stops[Math.floor(at)] ?? '#000000')
38  const to = channels(stops[Math.ceil(at)] ?? '#000000')
39
40  return `#${from
41    .map((one, index) =>
42      Math.round(one + ((to[index] ?? one) - one) * (at - Math.floor(at)))
43        .toString(16)
44        .padStart(2, '0'),
45    )
46    .join('')}`
47}
48
49const clean = atom({ plugin: 'toolbar', key: 'clean' } as const, EMPTY)
50
51// The session keeps this value across reloads, so one saved by an older
52// version of the mod may lack a field: every read and write starts from EMPTY.
53export const wholeClean = (saved: Partial<Clean> | undefined): Clean => ({ ...EMPTY, ...saved })
54
55const load = async ($: EngineInterface) => wholeClean(await read($, clean))
56
57const change = ($: EngineInterface, fn: (one: Clean) => Clean) =>
58  update($, clean, saved => fn(wholeClean(saved)))
59
60// The final messages that stay drawn: the answer to what the person last
61// asked, and after it the latest a background task's own turn ended with, so
62// a task reporting in never takes the answer away.
63const stop = ($: EngineInterface, final?: string, isAsked = true) =>
64  change($, one => ({
65    ...one,
66    working: false,
67    finals:
68      final === undefined || final.trim() === ''
69        ? one.finals
70        : isAsked
71          ? [final]
72          : [one.finals[0] ?? '', final],
73  }))
74
75// Where a prompt the person sent comes from; any other origin is a task, a
76// peer or a schedule speaking, and its turn does not replace their answer.
77const PERSON = ['composer', 'bridge', 'sdk']
78// How many prompts may wait for their turn before the oldest are forgotten.
79const WAITING = 50
80
81// What the small model is asked for: a heading that says what the turn is
82// for, never the prompt read back.
83const NAMER = `Name the task the user's message asks for, as a heading of 3 to 6 words in the language of the message. Answer with the heading alone: no quotes, no full stop. Do not answer the message.`
84
85// Names the turn in the band. Until the heading arrives, and when none does,
86// the band has none: the prompt itself is already on screen, right above it.
87const entitle = async ($: EngineInterface, text: string, turnId: string) => {
88  try {
89    await change($, one => ({ ...one, title: '', turnId }))
90
91    // Nothing is asked of the model for a band that is not drawn.
92    if (text.trim() === '' || !(await load($)).on) {
93      return
94    }
95
96    const named = await $.model.complete({
97      model: 'haiku',
98      system: NAMER,
99      prompt: text.slice(0, 4000),
100      maxTokens: 40,
101      timeoutMs: 15000,
102    })
103
104    if (named.isAnswered) {
105      // The heading is drawn as plain text: the marks a model may put round it go.
106      const title = headed(named.text.replace(/[*_`#"“”]/g, '')).replace(/[.:]$/, '')
107      // A later turn has its own heading on the way: this one is stale.
108      await change($, one => (one.turnId === turnId ? { ...one, title } : one))
109    }
110  } catch {
111    // No heading, then.
112  }
113}
114
115// The loading bars move only while there is one to draw.
116const tick = async ($: EngineInterface) => {
117  const { on: isOn, tasks } = await load($)
118
119  if (isOn && tasks.some(task => task.status === 'in_progress')) {
120    await update($, beat, at => (at ?? 0) + 1)
121  }
122}
123
124export const registerClean: Register = on => {
125  let pulse: Timer | undefined
126
127  const rest = () => {
128    pulse?.cancel()
129    pulse = undefined
130  }
131
132  // Whether the turn under way answers the person, and for each prompt still
133  // to start its turn, whether the person sent it: a prompt typed over a
134  // running turn waits, and a task may report in before it starts.
135  let isAsked = true
136  const sent = new Map<string, boolean>()
137
138  on('prompt.submit', ($, e, next) => {
139    if (sent.size >= WAITING) {
140      sent.clear()
141    }
142
143    sent.set(e.text, PERSON.includes(e.origin.kind))
144
145    return next(e)
146  })
147
148  // A list that was finished belongs to the turn before: the next starts clean.
149  on('turn.start', async ($, e, next) => {
150    rest()
151    pulse = $.clock.every(BEAT, () => void tick($))
152    const isPerson = sent.get(e.text)
153    sent.delete(e.text)
154    // A prompt whose sender is unknown (the module reloaded since it was sent)
155    // is drawn as the person's, but its text is not sent anywhere for that.
156    isAsked = isPerson ?? true
157
158    if (isPerson === true) {
159      // Not awaited: the turn does not wait for its heading.
160      void entitle($, e.text, e.turnId)
161    }
162    await change($, one => ({
163      ...one,
164      working: true,
165      tasks: one.tasks.every(task => task.status === 'completed') ? [] : one.tasks,
166    }))
167
168    return next(e)
169  })
170
171  on('turn.complete', async ($, e, next) => {
172    // A subagent's turn ends inside the main one, which is still working.
173    if (e.agentId === undefined) {
174      rest()
175      await stop($, e.answer, isAsked)
176    }
177
178    return next(e)
179  })
180
181  on('turn.abort', async ($, e, next) => {
182    rest()
183    await stop($)
184
185    return next(e)
186  })
187
188  // The list is followed whether clean view is on or not, so turning it on in
189  // the middle of a turn shows the tasks already made.
190  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
191    const ran = await next(e)
192
193    if (ran.deny === undefined && ran.isError !== true) {
194      const { id, subject } = ran.result.task
195      await change($, one => ({
196        ...one,
197        tasks: [...one.tasks.filter(task => task.id !== id), { id, subject, status: 'pending' }],
198      }))
199    }
200
201    return ran
202  })
203
204  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
205    const ran = await next(e)
206
207    if (ran.deny === undefined && ran.isError !== true) {
208      const { taskId, status, subject } = e
209      await change($, one => ({
210        ...one,
211        tasks:
212          status === 'deleted'
213            ? one.tasks.filter(task => task.id !== taskId)
214            : one.tasks.map(task =>
215                task.id === taskId
216                  ? { ...task, status: status ?? task.status, subject: subject ?? task.subject }
217                  : task,
218              ),
219      }))
220    }
221
222    return ran
223  })
224
225  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
226    const ran = await next(e)
227
228    if (ran.deny === undefined && ran.isError !== true) {
229      const tasks = e.todos.map((todo, index) => ({
230        id: String(index),
231        subject: todo.content,
232        status: todo.status,
233      }))
234      await change($, one => ({ ...one, tasks }))
235    }
236
237    return ran
238  })
239
240  on('prompt.compose', async ($, e, next) => {
241    const composed = await next(e)
242
243    if (!(await load($)).on) {
244      return composed
245    }
246
247    return {
248      ...composed,
249      sections: [...composed.sections, { id: 'toolbar-clean-view', text: GUIDE, scope: 'session' }],
250    }
251  })
252
253  // The rows of the work itself draw nothing while clean view is on; the
254  // transcript keeps them, so turning it off shows them all again.
255  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
256    const { Box } = $.ui.resolve(e)
257
258    return (await load($)).on ? <Box /> : next(e)
259  })
260
261  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
262    const { Box } = $.ui.resolve(e)
263
264    return (await load($)).on ? <Box /> : next(e)
265  })
266
267  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
268    const { Box } = $.ui.resolve(e)
269
270    return (await load($)).on ? <Box /> : next(e)
271  })
272
273  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
274    const { Box } = $.ui.resolve(e)
275
276    return (await load($)).on ? <Box /> : next(e)
277  })
278
279  // The engine's own line of the turn, with its copy of the task list, and the
280  // line that closes it: the band says both while clean view is on.
281  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
282    const { Box } = $.ui.resolve(e)
283
284    return (await load($)).on ? <Box /> : next(e)
285  })
286
287  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
288    const { Box } = $.ui.resolve(e)
289
290    return (await load($)).on ? <Box /> : next(e)
291  })
292
293  // A background task reporting that it ended is part of the work too; what
294  // the person typed stays, and so does a task that failed or was stopped.
295  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
296    const isDone = e.props.task?.status === undefined || e.props.task.status === 'completed'
297
298    if (
299      e.props.origin.kind !== 'task-notification' ||
300      !isDone ||
301      e.props.isExpanded ||
302      !(await load($)).on
303    ) {
304      return next(e)
305    }
306
307    const { Box } = $.ui.resolve(e)
308
309    return <Box />
310  })
311
312  // Of the model's text, only what those turns ended with is drawn.
313  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
314    const { on: isOn, finals } = await load($)
315    const text = e.props.text.trim()
316
317    if (!isOn || (text !== '' && finals.some(final => final.includes(text)))) {
318      return next(e)
319    }
320
321    const { Box } = $.ui.resolve(e)
322
323    return <Box />
324  })
325}
326
types/index.d.ts 49 lines
1export type Toolbar = {
2  // The session's model as the engine names it ("claude-opus-5-5[1m]").
3  model: string
4  // The effort last set here or seen on a turn; null until either happens.
5  effort: string | null
6  // The model names `/model` takes in this build ("opus", "opus[1m]", ...).
7  options: string[]
8  // Whether the card is drawn above the prompt.
9  open: boolean
10  // What was picked in the card and not yet applied: `/model`'s argument and
11  // the effort level. Applied, once each, when the card closes.
12  pick: { model: string | null; effort: string | null }
13}
14
15export type Task = {
16  id: string
17  subject: string
18  status: 'pending' | 'in_progress' | 'completed'
19}
20
21export type Clean = {
22  // Clean view: the transcript shows the task list and each turn's final
23  // message, and nothing of the work in between.
24  on: boolean
25  // True from a turn's start to its end.
26  working: boolean
27  // The model's task list, as its task tools last left it.
28  tasks: Task[]
29  // The final messages that stay drawn: the answer to what the person last
30  // asked, then the latest a background task's own turn ended with.
31  finals: string[]
32  // A few words naming what the person's latest turn is for, written by a
33  // small model: the band's heading, empty until it arrives.
34  title: string
35  // The turn that heading is for.
36  turnId: string
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    // `beat` counts the ticks of the band's loading bars while a turn works: a
42    // value of its own, so a tick redraws the band and not the transcript.
43    toolbar: { view: Toolbar; clean: Clean; beat: number }
44    // The english-coach mod's switch, as its own contract declares it: read
45    // for the card's row, never written here.
46    'english-coach': { on: boolean }
47  }
48}
49