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…

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.
| Mod | What it does |
|---|---|
english-coach | Checks 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 |
toolbar | A 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 |
statusline | A 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 |
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.
Both mods make extra model requests on your account. Read this before installing.
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.
! shell lines, and prompts with fewer than four of your own words left after stripping./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.SYSTEM in english-coach/hooks/register.tsx to describe yourself.Adds a Toolbar button at the right of the hint row under the prompt (also /toolbar). It opens a card above the prompt to:
/model and one /effort when the card closes;open -a "Visual Studio Code", so macOS only);/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.
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.
hooks/register.tsx 457 lines1import { 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}
457hooks/clean.tsx 326 lines1import { 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}
326types/index.d.ts 49 lines1export 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