A clickable effort meter in the prompt footer, five squares from low to max. A click sets that level for the model, as Alt+P does

<!-- mod-model-effort / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->
Part of [moonlight-claude-mods][doc-root].
Shows the model and effort level; a click changes either.
Inside Claude Code:
/plugin marketplace add v-amorim/moonlight-claude-mods
/plugin install mod-model-effort@claude-mods
/reload-plugins
/plugin install asks where to install the mod. Keep Install for you (user scope) and press Enter, then press Esc to leave the plugin screen before /reload-plugins.
To install every mod at once, see [Install][doc-install]. For sharper icons, see [Icons and Nerd Fonts][doc-icons].
The model pill.
| The model panel |
|---|
| ![][img-model-effort-panel] |
| Effort on hover, models on click |
|---|
| https://github.com/user-attachments/assets/bc90d5e3-6bf5-4981-8bdd-410abb7465c8 |
~/.claude/mods-data/mod-model-effort/bridge/<session id>.jsonl (under $CLAUDE_CONFIG_DIR when that is set), and the mod reads the file every 250 ms. A record can hold the session id, the working folder and the transcript path. SessionEnd deletes the session's file, and SessionStart deletes files older than 7 days. The hooks need a POSIX shell (Git Bash on Windows). Without one they write nothing, and the mod falls back to what it can find on its own. Some plans block plugin hooks on these events; the mod then falls back the same way.Change them with /mod-model-effort settings or the gear pill.
| Setting | Default | What it does |
|---|---|---|
| Icons | auto | auto picks Nerd Font or safe glyphs. It uses Nerd Font icons when the terminal's font is a Nerd Font (read from Windows Terminal, iTerm2, VS Code, Ghostty, kitty, WezTerm, Alacritty, GNOME Terminal or Konsole) and safe Unicode glyphs elsewhere. nerd and safe force one |
| Animations | on | Entries shimmer while they wait. Hover colors also fade. Off, everything draws still |
Made by [Vinicius Amorim][doc-author], [MIT license][doc-license].
[doc-author]: https://github.com/v-amorim [doc-icons]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#icons-and-nerd-fonts [doc-install]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#install [doc-license]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/LICENSE [doc-root]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md
[img-model-effort-panel]: https://github.com/user-attachments/assets/296723ee-5481-4212-a954-c81aa20f1d05
hooks/register.tsx 635 lines1// mod-model-effort / hooks/register.tsx, by v-amorim
2// Registers the mod: wires the meter, settings pane, commands, toasts and model switching.
3import type { ConfigValue, EngineInterface, Register, RenderElement } from 'claude-code'
4
5import { bridgeOn, endBridge, startBridge, type BridgeRecord } from './bridge'
6import { displayName, LEVEL_LOOK, LEVELS, plainModel, withEffort, withModel } from './levels'
7import { entryCells, isTipMessage, tipText, type EntryMessage, type EntryProps, type Tip, type TipMessage } from './entry'
8import { cardFill, detectNerd, FOOTER_ROWS, lookFor, paneFooter, sepText, titleEdge, type IconMode, withMark } from './look'
9import { meterCells, type MeterMessage } from './meter'
10import { settingChoice, settingsBody } from './settings'
11import style from './style'
12import { firstText, isSpeed, replySpeed, watch } from './speed'
13import { fill, MARK, statusColor, THEME } from './theme'
14import { labelsOf, PILL, pill, pillRow, splitRow, tabRow, tipLine } from './pills'
15import text from './text'
16import { TOAST_MS, ToastList, toastCards } from './toasts'
17
18const homeOf = async ($: EngineInterface) => ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
19
20const configDir = async ($: EngineInterface) => ((await $.env.get('CLAUDE_CONFIG_DIR')) || `${await homeOf($)}/.claude`).replace(/\\/g, '/')
21
22const PANE ='model-effort-settings'
23
24// Streams the step as it is and keeps the speed of a reply that came out whole.
25async function* timed<C extends { kind: string }, R extends { usage: { output_tokens: number } | null }>($: EngineInterface, open: () => AsyncGenerator<C, R>): AsyncGenerator<C, R> {
26 // A clock that fails only costs the reading, never the stream.
27 const tick = () => $.clock.now().catch(() => undefined)
28 const start = await tick()
29 let first: number | undefined
30 const result = yield* watch(open(), async chunk => {
31 if (first === undefined && (chunk.kind === 'text' || chunk.kind === 'thinking')) first = await tick()
32 })
33 const end = await tick()
34 const speed = start === undefined || end === undefined ? null : replySpeed(start, first, end, result.usage?.output_tokens)
35 if (speed) await $.state.set({ plugin: 'mod-model-effort', key: 'speed' }, speed)
36 return result
37}
38
39// The tip with the last reply's speed after it; as it was before any reply.
40const withSpeed = (tip: Tip, speed: unknown): Tip => {
41 if (!isSpeed(speed)) return tip
42 return { ...tip, text: `${tip.text} ${MARK.sep} ${fill(text.tipSpeed, { first: firstText(speed.first), rate: String(speed.rate) })}` }
43}
44
45// The icons setting (userConfig), fixed per activation: a change reloads the mod.
46let iconMode: IconMode = 'auto'
47let look = lookFor(false)
48let note = ''
49let flash: string | null = null
50// The clicked level and the level before it; a saved level other than these means Alt+P or /effort took over.
51let override: { effort: string; before: string } | null = null
52
53// Nerd glyphs when the setting asks for them, or on auto when the terminal's font is a Nerd Font.
54async function detectLook($: EngineInterface) {
55 if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
56 const env = {
57 WT_SESSION: await $.env.get('WT_SESSION'),
58 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
59 ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
60 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
61 ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
62 GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
63 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
64 }
65 const host = {
66 localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
67 home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
68 read: (path: string) => $.fs.read(path).then(String),
69 run: async (argv: string[]) => {
70 const result = await $.process.run(argv, { timeoutMs: 5000 })
71 if (result.exitCode !== 0) throw new Error(result.stderr)
72 return result.stdout
73 },
74 }
75 return lookFor(await detectNerd(env, host))
76}
77
78// Reading the terminal's settings is slow, so on auto the last answer in the store draws until it returns.
79async function savedLook($: EngineInterface) {
80 if (iconMode !== 'auto') return look
81 try {
82 const nerd = await $.store.get('look-nerd')
83 return typeof nerd === 'boolean' ? lookFor(nerd) : look
84 } catch {
85 return look
86 }
87}
88
89async function saveLook($: EngineInterface) {
90 try {
91 await $.store.set('look-nerd', look.nerd)
92 } catch {
93 // Without a store the mod still draws; it only starts from safe again next time.
94 }
95}
96
97// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
98let lookLoad: Promise<void> | null = null
99
100function loadLook($: EngineInterface) {
101 lookLoad ??= (async () => {
102 look = await savedLook($)
103 void detectLook($).then(async found => {
104 const isChanged = found.nerd !== look.nerd
105 look = found
106 await saveLook($)
107 if (isChanged) $.ui.invalidate('ui.render')
108 }).catch(() => {
109 // A failed check keeps the icons drawn now.
110 })
111 })()
112 return lookLoad
113}
114
115const isPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
116
117// `/mod-model-effort settings` toggles the panel. An unplaced pane is waiting, not open.
118async function togglePane($: EngineInterface) {
119 if (await isPaneOpen($)) {
120 await $.ui.close({ id: PANE })
121 await $.state.set({ plugin: 'mod-model-effort', key: 'pane' }, false)
122 return
123 }
124 note = ''
125 const opened = await $.ui.open({ id: PANE, title: text.paneName, closeOnEscape: true })
126 if (!opened.isPlaced) $.ui.toast(fill(text.settingsWaiting, { reason: opened.reason }))
127 await $.state.set({ plugin: 'mod-model-effort', key: 'pane' }, opened.isPlaced)
128}
129
130// Saves the clicked level for the main model, as Alt+P does; the next request's effort shows whether it took.
131async function setEffort($: EngineInterface, effort: string) {
132 const current = (await $.state.get({ plugin: 'mod-model-effort', key: 'current' })).value
133 if (!current) {
134 $.ui.toast(text.sendFirst)
135 return
136 }
137 const path = `${await configDir($)}/settings.json`
138 // A fresh machine has no settings.json yet: start from {} and create it.
139 const isMissing = !(await $.fs.exists(path).catch(() => true))
140 let raw: string
141 try {
142 raw = isMissing ? '{}' : String(await $.fs.read(path))
143 } catch {
144 $.ui.toast(fill(text.settingsNotJson, { mark: MARK.warn }))
145 return
146 }
147 const out = withEffort(raw, current.model, effort)
148 if (!out) {
149 $.ui.toast(fill(text.settingsNotJson, { mark: MARK.warn }))
150 return
151 }
152 // The first backup is the one worth keeping: later clicks never replace it.
153 const backup = `${path}.mod-model-effort.bak`
154 if (!isMissing && !(await $.fs.exists(backup).catch(() => false))) await $.fs.write(backup, raw)
155 await $.fs.write(path, out)
156 override = { effort, before: current.effort }
157 await $.state.set({ plugin: 'mod-model-effort', key: 'current' }, { model: current.model, effort })
158 flash = effort
159 $.ui.invalidate('ui.render')
160 $.clock.after(2000, () => {
161 if (flash !== effort) return
162 flash = null
163 $.ui.invalidate('ui.render')
164 })
165}
166
167
168// The level Claude Code reads for a model: the model's own, else the one for every model.
169async function savedEffort($: EngineInterface, model: string) {
170 const settings = await $.settings.read()
171 const models = settings.modelSettings as Record<string, { effortLevel?: unknown }> | undefined
172 const effort = models?.[model]?.effortLevel ?? settings.effortLevel
173 return typeof effort === 'string' ? effort : null
174}
175
176// The pill's model and level, and the model the store keeps for the next session's start.
177async function setCurrent($: EngineInterface, model: string, effort: string) {
178 await $.state.set({ plugin: 'mod-model-effort', key: 'current' }, { model, effort })
179 await $.store.set('model', model)
180 $.ui.invalidate('ui.render')
181}
182
183// The session's model as the last render saw it.
184let lastLive: string | undefined
185
186// The pill moves to a model the session switched to, at the level saved for it.
187async function followModel($: EngineInterface, to: string) {
188 const current = (await $.state.get({ plugin: 'mod-model-effort', key: 'current' })).value
189 // A clicked level was set on the old model; the new model's saved level applies.
190 if (to !== current?.model) override = null
191 await setCurrent($, to, (await savedEffort($, to)) ?? current?.effort ?? 'medium')
192}
193
194// Alt+P and /effort save the level to the settings, so a settings change shows it on the pill at once.
195async function followSavedLevel($: EngineInterface) {
196 const current = (await $.state.get({ plugin: 'mod-model-effort', key: 'current' })).value
197 const saved = current ? await savedEffort($, current.model) : null
198 if (!current || !saved) return
199 // Same rule as turn.step: a saved level other than the clicked one and the one before it means another level took over.
200 if (override && saved !== override.effort && saved !== override.before) override = null
201 const effort = override?.effort ?? saved
202 if (effort === current.effort) return
203 await $.state.set({ plugin: 'mod-model-effort', key: 'current' }, { model: current.model, effort })
204 $.ui.invalidate('ui.render')
205}
206
207// A reload makes the bridge replay the last record of each event once. The first record of an event after this module started is skipped when it is the one already handled, so a replayed old switch never moves the pill back.
208const replayChecked = new Set<string>()
209const REPLAY_KEPT_MS = 30 * 24 * 60 * 60 * 1000
210const replayKey = (session: string, event: string) => `bridge-last:${session}:${event}`
211
212async function isReplay($: EngineInterface, record: BridgeRecord) {
213 const key = replayKey(String(record.session_id ?? ''), String(record.hook_event_name ?? ''))
214 const raw = JSON.stringify(record)
215 const isFirst = !replayChecked.has(key)
216 replayChecked.add(key)
217 const kept = (await $.store.get(key).catch(() => undefined)) as { raw?: unknown } | undefined
218 await $.store.set(key, { raw, at: await $.clock.now() }).catch(() => undefined)
219 return isFirst && kept?.raw === raw
220}
221
222// Keys of sessions long gone leave the store.
223async function pruneReplays($: EngineInterface) {
224 try {
225 const now = await $.clock.now()
226 for (const key of await $.store.keys()) {
227 if (key.startsWith('bridge-last:') && Number(((await $.store.get(key)) as { at?: unknown } | null)?.at) < now - REPLAY_KEPT_MS) await $.store.delete(key)
228 }
229 } catch {
230 // Old entries stay until the next start.
231 }
232}
233
234// /model and the Alt+P picker switch without a request, so the pill follows at once. A failed read never blocks the switch.
235async function modelSwitched($: EngineInterface, record: BridgeRecord) {
236 try {
237 if (await isReplay($, record)) return
238 if (typeof record.to_model === 'string' && record.to_model) await followModel($, record.to_model)
239 } catch {
240 // The next request shows the model and level.
241 }
242}
243
244// Alt+P and /effort save the level to the user settings; any other source leaves the pill alone.
245async function configChanged($: EngineInterface, record: BridgeRecord) {
246 try {
247 if (record.source === 'user_settings') await followSavedLevel($)
248 } catch {
249 // The next request shows the level.
250 }
251}
252
253// The level last seen in the settings for the pill's model, and whether a check of it is already waiting.
254let lastSaved: { model: string; effort: string | null } | undefined
255let isCheckingLevel = false
256
257// The ConfigChange record can fail to arrive, so a render looks for a changed saved level. Only a change counts, so a level a request downgraded stays.
258async function followSavedChange($: EngineInterface) {
259 const current = (await $.state.get({ plugin: 'mod-model-effort', key: 'current' })).value
260 if (!current) return
261 const seen = lastSaved
262 lastSaved = { model: current.model, effort: await savedEffort($, current.model) }
263 if (seen?.model === current.model && seen.effort !== lastSaved.effort) await followSavedLevel($)
264}
265
266// The pill part or menu entry under the pointer, shown in the tip row.
267let bandTip: TipMessage | null = null
268
269// A band redraw can replace a hovered entry, whose leave and quiet timer then never come, so a band tip ends on its own.
270const BAND_TIP_MS = 5000
271let bandTipAt = 0
272
273function expireBandTip($: EngineInterface) {
274 const at = ++bandTipAt
275 $.clock.after(BAND_TIP_MS, () => {
276 if (at !== bandTipAt || !bandTip) return
277 bandTip = null
278 $.ui.invalidate('ui.render')
279 })
280}
281
282// The settings pane's control under the pointer, shown in its footer.
283let paneTip: Tip | null = null
284
285// The level under the pointer, named beside the pill like a clicked level is.
286const hoveredLevel = () => (bandTip?.id?.startsWith('effort-level-') ? bandTip.id.slice('effort-level-'.length) : null)
287
288// The model menu above the pill, opened by a click on the model name.
289let isModelMenuOpen = false
290
291// The effort card above the pill, opened by a click on a compact meter.
292let isEffortCardOpen = false
293
294// Whether Claude is working, as the band's last draw saw it: `/model` run by a click would queue as a prompt.
295let isWorking = false
296
297// The models used before, newest first, as the menu last drew them; an entry's id is its index.
298let menuModels: string[] = []
299
300// Switches through Claude Code's own `/model`, then shows the pick at once instead of after the next request.
301async function switchModel($: EngineInterface, id: string) {
302 isModelMenuOpen = false
303 bandTip = null
304 try {
305 await $.command.run({ command: 'model', args: id })
306 } catch (error) {
307 $.ui.toast(fill(text.switchFailed, { mark: MARK.warn, model: displayName(id), error: String(error) }))
308 return
309 }
310 const current = (await $.state.get({ plugin: 'mod-model-effort', key: 'current' })).value
311 await setCurrent($, id, current?.effort ?? 'medium')
312}
313
314// Claude Code's own picker has every model, new ones included.
315async function openPicker($: EngineInterface) {
316 isModelMenuOpen = false
317 bandTip = null
318 $.ui.invalidate('ui.render')
319 await $.command.run({ command: 'model', args: '' }).catch(error => $.ui.toast(fill(text.pickerFailed, { mark: MARK.warn, error: String(error) })))
320}
321
322type Elements = { Box: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
323
324// A model entry as its own pill on the card; `entry` holds what differs between the current model, the others and the picker.
325const menuEntry = ({ Box, Client, Text }: Elements, id: string, fill: string, entry: Omit<EntryProps, 'id' | 'bg'>) =>
326 pill({ Box, Text }, look.nerd, fill, <Client key={id} module="./entry.tsx" height={1} props={{ id, bg: fill, nerd: look.nerd, still, ...entry } satisfies EntryProps} />, `${id}-pill`, cardFill())
327
328// A control glyph without the brackets and padding it carries as a Button label, for drawing as a subpill.
329const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
330
331// The models used before (the current one with a ✓), the full picker and ×: one line while it fits the band, else stacked.
332function modelMenu(elements: Elements, currentId: string | undefined, columns: number) {
333 const { Box } = elements
334 const ok = bare(look.glyph.ok)
335 // While Claude works a pick would queue as a prompt, so the entries draw muted and a click only says why.
336 const live = !isWorking
337 // Stacked, the others start with blanks as wide as the ✓ subpill, so every name lines up.
338 const blank = ' '.repeat([...ok].length + 2)
339 const models: [string, string, Omit<EntryProps, 'id' | 'bg'>][] = menuModels.map((id, index) =>
340 currentId && id === plainModel(currentId)
341 ? [`effort-model-${index}`, THEME.surface3, { label: displayName(id), isOpen: live, idle: THEME.muted, subLead: ok, subLeadColor: THEME.ok, tip: { glyph: ok, label: displayName(id), text: text.tipModelEntry } }]
342 : [`effort-model-${index}`, THEME.surface2, { label: displayName(id), isOpen: live, idle: THEME.muted, tip: { glyph: ok, label: displayName(id), text: text.tipModelEntry } }],
343 )
344 const all: [string, string, Omit<EntryProps, 'id' | 'bg'>] = ['effort-model-all', THEME.surface2, { label: text.allModels, isOpen: false, subLead: bare(look.glyph.go), tip: { glyph: bare(look.glyph.go), label: text.allModels, text: text.tipAll } }]
345 // The × closes the menu without a pick, as on a toast card.
346 const close: [string, string, Omit<EntryProps, 'id' | 'bg'>] = ['effort-model-close', THEME.surface3, { label: bare(look.glyph.close), isOpen: false, tip: { glyph: bare(look.glyph.close), label: text.labelClose, text: text.tipModelClose } }]
347 const line = [...models, all, close]
348 // Each pill is its entry plus two caps on Nerd; the card adds its border and padding, two cells a side.
349 const lineCells = line.reduce((sum, [id, bg, entry]) => sum + entryCells({ id, bg, nerd: look.nerd, ...entry }) + (look.nerd ? 2 : 0), line.length - 1 + 4)
350 const draw = ([id, bg, entry]: [string, string, Omit<EntryProps, 'id' | 'bg'>]) => menuEntry(elements, id, bg, entry)
351 const stacked = models.map(([id, bg, entry]) => draw([id, bg, entry.subLead ? entry : { ...entry, label: `${blank}${entry.label}` }]))
352 return (
353 <Box key="effort-models-row" flexDirection="row" justifyContent="flex-end">
354 <Box key="effort-models-block" flexDirection="column">
355 {lineCells <= columns ? (
356 <Box key="effort-models" flexDirection="row" columnGap={1} paddingX={1} borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()}>
357 {line.map(draw)}
358 </Box>
359 ) : (
360 <Box key="effort-models" flexDirection="column" alignItems="flex-start" paddingX={1} borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()}>
361 <Box key="effort-models-head" flexDirection="row" columnGap={1}>
362 {stacked[0] ?? null}
363 {draw(close)}
364 </Box>
365 {stacked.slice(1)}
366 {draw(all)}
367 </Box>
368 )}
369 {titleEdge(elements, look, 'effort-models', '', text.labelModel, '', 0)}
370 </Box>
371 </Box>
372 )
373}
374
375// The effort card above the pill row: the meter held open, then a × pill; opened by a click on a compact meter.
376function effortCard(elements: Elements, meterProps: Record<string, unknown>) {
377 const { Box, Client } = elements
378 const close = menuEntry(elements, 'effort-card-close', THEME.surface3, { label: bare(look.glyph.close), isOpen: false, tip: { glyph: bare(look.glyph.close), label: text.labelClose, text: text.tipCardClose } })
379 return (
380 <Box key="effort-card-row" flexDirection="row" justifyContent="flex-end">
381 <Box key="effort-card-block" flexDirection="column">
382 <Box key="effort-card" flexDirection="row" columnGap={1} paddingX={1} borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()}>
383 <Client key="effort-card-meter" module="./meter.tsx" height={1} props={{ ...meterProps, card: true, compact: false, under: cardFill() }} />
384 {close}
385 </Box>
386 {titleEdge(elements, look, 'effort-card', '', text.labelEffort, '', 0)}
387 </Box>
388 </Box>
389 )
390}
391
392const toasts = new ToastList('effort')
393// The animations setting, fixed per activation: off, every entry draws still.
394let still = false
395
396// A settings card's click: its row, saved through /config; a saved change reloads this mod.
397async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
398 const row = (await $.config.list()).find(each => each.key === key)
399 if (!row) return
400 const result = await $.config.set({ key: row.key, value })
401 note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.settingSaved, { label: row.label })}`
402 $.ui.invalidate('ui.render')
403}
404
405export const register: Register = (on, options) => {
406 still = options.animations === false
407 toasts.isStill = still
408 iconMode = (options.icons as IconMode | undefined) ?? 'auto'
409 look = lookFor(iconMode === 'nerd')
410 // This mod's toasts draw as cards in the band; Claude Code's own box is narrow and cuts them.
411 on('ui.toast', async ($, e) => {
412 const id = toasts.add(e.text)
413 $.ui.invalidate('ui.render')
414 $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
415 toasts.drop(id)
416 $.ui.invalidate('ui.render')
417 })
418 return { value: undefined }
419 })
420
421 on('session.start', async ($, e, next) => {
422 // The classic hooks never reach a mod, so the command hooks in hooks.json hand these over through the bridge.
423 bridgeOn('PostModelSwitch', record => modelSwitched($, record))
424 bridgeOn('ConfigChange', record => configChanged($, record))
425 await startBridge($, 'mod-model-effort')
426 await pruneReplays($)
427 await loadLook($)
428 await $.command.register({ name: 'mod-model-effort', description: text.commandDescription, argumentHint: '[settings]' })
429 await $.state.set({ plugin: 'mod-model-effort', key: 'pane' }, await isPaneOpen($))
430 // Before the first request the session's own model stands in, since `--model` can differ from the last session's.
431 const own = await $.session.model().catch(() => undefined)
432 const model = typeof own === 'string' && own ? own : await $.store.get('model')
433 if (typeof model === 'string') {
434 const effort = await savedEffort($, model)
435 if (effort) await $.state.set({ plugin: 'mod-model-effort', key: 'current' }, { model, effort })
436 }
437
438 return next(e)
439 })
440
441 on('session.end', async ($, e, next) => {
442 // What the ended session's file still holds is delivered before the session id changes under it.
443 await endBridge($, e.sessionId)
444 for (const event of ['PostModelSwitch', 'ConfigChange']) await $.store.delete(replayKey(e.sessionId, event)).catch(() => undefined)
445
446 return next(e)
447 })
448
449 on('command.run', { command: 'mod-model-effort' }, async ($, e) => {
450 if (e.args.trim() !== 'settings') return { text: text.usage }
451 await togglePane($)
452 return { text: '' }
453 })
454
455 on('ui.close', async ($, e, next) => {
456 if (e.id === PANE) await $.state.set({ plugin: 'mod-model-effort', key: 'pane' }, false)
457
458 return next(e)
459 })
460
461 on('state.set', async ($, e, next) => {
462 // The declared state names no other mod's pane, so the types narrow this event to never.
463 if (e.key === 'pane' && e.plugin !== 'mod-model-effort' && (e as { value?: unknown }).value === true) {
464 $.clock.after(0, async () => {
465 if (!(await isPaneOpen($))) return
466 await $.ui.close({ id: PANE })
467 await $.state.set({ plugin: 'mod-model-effort', key: 'pane' }, false)
468 })
469 }
470
471 return next(e)
472 })
473
474 // Each main request carries the real model id and effort, so the meter always shows what went out.
475 on('turn.step', async function* ($, e, next) {
476 if (e.agentId) return yield* next(e)
477 if (typeof e.effort !== 'string') return yield* timed($, () => next(e))
478
479 // Claude Code reads the saved level only at startup, so a clicked level rides on each request until Alt+P or /effort saves another.
480 if (override) {
481 const settings = await $.settings.read({ source: 'user' })
482 const saved = (settings.modelSettings as Record<string, { effortLevel?: unknown } | undefined> | undefined)?.[e.model]?.effortLevel
483 if (saved !== override.effort && saved !== override.before) override = null
484 }
485 const effort = override?.effort ?? e.effort
486 const current = (await $.state.get({ plugin: 'mod-model-effort', key: 'current' })).value
487 if (current?.model !== e.model || current?.effort !== effort) {
488 await $.state.set({ plugin: 'mod-model-effort', key: 'current' }, { model: e.model, effort })
489 await $.store.set('model', e.model)
490 }
491
492 const result = yield* timed($, () => next(effort === e.effort ? e : { ...e, effort: effort as typeof e.effort }))
493 // Only a model the API answered joins the menu, so a mistyped or retired id never stays there.
494 const models = await $.store.get('models')
495 if (result.usage && !(Array.isArray(models) && models[0] === plainModel(e.model))) await $.store.set('models', withModel(models, e.model))
496 return result
497 })
498
499 on('ui.message', async ($, e, next) => {
500 if (isTipMessage(e.data)) {
501 const tip = e.data
502 const own = Boolean(tip.id?.startsWith('effort-') || tip.id?.startsWith('toast-effort-'))
503 bandTip = own ? tip : null
504 if (bandTip?.id) expireBandTip($)
505 paneTip = tip.id && !own ? { glyph: tip.glyph ?? '', label: tip.label ?? '', text: tip.text ?? '' } : null
506 $.ui.invalidate('ui.render')
507 return next(e)
508 }
509 const change = settingChoice(e.data)
510 if (change) await saveSetting($, change.key, change.value)
511 const clicked = e.data as EntryMessage
512 const dismissed = clicked?.type === 'menu-entry' ? toasts.idOf(clicked.id) : undefined
513 if (dismissed !== undefined) {
514 toasts.drop(dismissed)
515 $.ui.invalidate('ui.render')
516 return next(e)
517 }
518 if (clicked?.type === 'menu-entry' && clicked.id === 'effort-card-close') {
519 isEffortCardOpen = false
520 bandTip = null
521 $.ui.invalidate('ui.render')
522 return next(e)
523 }
524 if (clicked?.type === 'menu-entry' && clicked.id === 'effort-model-close') {
525 isModelMenuOpen = false
526 bandTip = null
527 $.ui.invalidate('ui.render')
528 return next(e)
529 }
530 if (clicked?.type === 'menu-entry' && clicked.id === 'effort-model-all') {
531 if (isWorking) $.ui.toast(fill(text.modelBusy, { name: text.allModels }))
532 else await openPicker($)
533 return next(e)
534 }
535 const picked = clicked?.type === 'menu-entry' && clicked.id.startsWith('effort-model-') ? menuModels[Number(clicked.id.slice('effort-model-'.length))] : undefined
536 if (picked && isWorking) {
537 $.ui.toast(fill(text.modelBusy, { name: displayName(picked) }))
538 return next(e)
539 }
540 if (picked) {
541 await switchModel($, picked)
542 return next(e)
543 }
544 const message = e.data as MeterMessage
545 if (message?.type === 'effort-card') {
546 isEffortCardOpen = !isEffortCardOpen
547 isModelMenuOpen = false
548 bandTip = null
549 $.ui.invalidate('ui.render')
550 }
551 if (message?.type === 'model-menu') {
552 isEffortCardOpen = false
553 isModelMenuOpen = !isModelMenuOpen
554 bandTip = null
555 const current = (await $.state.get({ plugin: 'mod-model-effort', key: 'current' })).value
556 menuModels = withModel(await $.store.get('models'), current?.model ?? (await $.session.model().catch(() => undefined)))
557 $.ui.invalidate('ui.render')
558 }
559 if (message?.type === 'set') {
560 isEffortCardOpen = false
561 bandTip = null
562 await setEffort($, message.effort)
563 }
564
565 return next(e)
566 })
567
568 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
569 await loadLook($)
570 const hidden = await $.state.get({ plugin: 'mod-menu', key: 'hidden' })
571 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
572 isWorking = e.props.isWorking
573 // With the menu hidden the pill steps aside, but a toast still needs its card.
574 if (hidden.value === true) {
575 if (toasts.items.length === 0) return next(e)
576 const { Box, Client, Text } = $.ui.resolve(e)
577 return <Box key="toasts-effort" flexDirection="column">{toastCards({ Box, Client, Text }, toasts, look, text.tipToast)}{await next(e)}</Box>
578 }
579
580 const { Box, Client, Text } = $.ui.resolve(e)
581 const current = await $.state.get({ plugin: 'mod-model-effort', key: 'current' })
582 let shown = current.value
583 // The PostModelSwitch record can fail to arrive, so the session's model is the signal too.
584 const live = await $.session.model().catch(() => undefined)
585 if (typeof live === 'string' && displayName(live) !== live) {
586 // Only a change of the session's model counts: a request on a fallback model leaves the pill as it went out.
587 const isSwitched = lastLive !== undefined && live !== lastLive
588 lastLive = live
589 if (shown && isSwitched && live !== shown.model) {
590 shown = { model: live, effort: (await savedEffort($, live).catch(() => null)) ?? shown.effort }
591 // A render only reads, so the state follows right after it.
592 $.clock.after(0, () => followModel($, live).catch(() => {}))
593 }
594 }
595 if (!isCheckingLevel) {
596 isCheckingLevel = true
597 $.clock.after(250, () => followSavedChange($).catch(() => {}).finally(() => { isCheckingLevel = false }))
598 }
599 const modelId = shown?.model ?? (await $.session.model().catch(() => undefined))
600 const model = displayName(modelId ?? '')
601 const { group, row, rest, tip } = splitRow(await next(e))
602 // The pill row cuts the meter to its level when the row overflows; labels never always does.
603 const labels = labelsOf((await $.state.get({ plugin: 'mod-menu', key: 'labels' })).value)
604 const fit = look.nerd && labels !== 'always' ? { cells: meterCells(model, look.nerd), compact: labels === 'never' } : {}
605 const speed = (await $.state.get({ plugin: 'mod-model-effort', key: 'speed' })).value
606 const meterProps = { effort: shown?.effort ?? null, model, bg: PILL.secondary, still, nerd: look.nerd, ...fit, tipModel: withSpeed({ glyph: look.glyph.title, label: text.labelModel, text: text.tipModel }, speed), tipRest: withSpeed({ glyph: look.glyph.title, label: text.paneName, text: text.tipRest }, speed), tipLevels: LEVELS.map(level => withSpeed({ glyph: LEVEL_LOOK[level]!.glyph, label: text.paneName, text: fill(text.tipLevel, { level: (text.levelNames as Record<string, string>)[level]! }) }, speed)) }
607 const meter = <Client key="meter" module="./meter.tsx" height={1} props={meterProps} />
608 // The level word follows the pill, so it never moves the glyphs under the pointer.
609 const shownWord = hoveredLevel() ?? flash
610 const word = shownWord ? <Text key="menu-flash" color={style.colors.pills.readout}>{(text.levelNames as Record<string, string>)[shownWord] ?? shownWord}</Text> : null
611
612 const menu = isModelMenuOpen ? [modelMenu({ Box, Client, Text }, modelId, e.props.bodyColumns)] : isEffortCardOpen ? [effortCard({ Box, Client, Text }, meterProps)] : []
613 return pillRow({ Box, Client, Text }, look.nerd, group, [...row, <Box key="menu-pill" flexDirection="row">{meter}</Box>, word], [...toastCards({ Box, Client, Text }, toasts, look, text.tipToast), ...rest, ...menu], bandTip?.id ? tipLine({ Box, Text }, tipText(bandTip)) : tip, e.props.bodyColumns)
614 })
615
616 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
617 await loadLook($)
618 if (e.requestId !== PANE) return next(e)
619
620 // @ts-expect-error The host's types omit Client from a Pane's elements, yet a Pane draws it.
621 const { Box, Button, Client, Text } = $.ui.resolve(e)
622 const rows = (await $.config.list()).filter(row => row.key.startsWith('mod-model-effort.'))
623
624 return (
625 <Box flexDirection="column" backgroundColor={THEME.bg} paddingX={1} minHeight={e.props.scroll.bodyRows}>
626 {tabRow({ Box, Client, Text }, look.nerd, [{ id: 'mod-model-effort-settings-tab', label: `${text.paneName} ${MARK.sep} ${text.paneSettings}`, icon: look.glyph.gear, isOpen: true, tip: { glyph: look.glyph.gear, label: text.paneSettings, text: text.tipTab } }], still)}
627 {sepText({ Text }, withMark(look, note) || `${text.statusHint} ${MARK.sep} ${text.statusHintSaved}`, statusColor(note), 'settings-status', { wrap: 'truncate-end' })}
628 {settingsBody({ Box, Button, Client, Text }, look, { rows, actions: [], working: false, run: async () => {}, previews: undefined, still: still })}
629 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
630 {paneFooter({ Box, Text }, paneTip, e.props.bodyColumns, e.props.scroll)}
631 </Box>
632 )
633 })
634}
635hooks/bridge.ts 107 lines1// hooks/bridge.ts, by v-amorim: a poll hands the lines a command hook appended to <mods-data>/<mod>/bridge/<session>.jsonl to this mod.
2import type { EngineInterface } from 'claude-code'
3
4export type BridgeRecord = Record<string, unknown>
5export type BridgeHandler = (record: BridgeRecord) => void | Promise<void>
6
7const POLL_MS = 250
8
9// One handler per event; registering again replaces it, so a mod that registers on every session.start holds one.
10const handlers = new Map<string, BridgeHandler>()
11// Characters of each session's file already delivered.
12const cursors = new Map<string, number>()
13let isStarted = false
14let isTicking = false
15let folder = ''
16
17export function bridgeOn(event: string, handler: BridgeHandler) {
18 handlers.set(event, handler)
19}
20
21async function folderOf($: EngineInterface, mod: string) {
22 const set = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
23 if (set) return `${set}/mods-data/${mod}/bridge`
24 // The command hooks write under $HOME, so it comes before USERPROFILE; a Git Bash `/c/Users/x` becomes `C:/Users/x`, which $.fs can open.
25 const raw = ((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/\\/g, '/').replace(/\/+$/, '')
26 const home = raw.replace(/^\/([A-Za-z])(?=\/[^/])/, (_, drive: string) => `${drive.toUpperCase()}:`)
27 return home ? `${home}/.claude/mods-data/${mod}/bridge` : ''
28}
29
30async function deliver(record: BridgeRecord) {
31 try {
32 await handlers.get(String(record.hook_event_name ?? ''))?.(record)
33 } catch {
34 // A handler's failure never stops the poll.
35 }
36}
37
38// The complete lines this session's file gained since the last read, and whether this is the first read since the module started.
39async function readNew($: EngineInterface, session: string) {
40 if (!folder || !session) return { lines: [] as string[], isFirst: false }
41 const text = String(await $.fs.read(`${folder}/${session}.jsonl`).catch(() => ''))
42 const isFirst = !cursors.has(session)
43 let cursor = cursors.get(session) ?? 0
44 // A file shorter than what was read was deleted or emptied, so it starts over.
45 if (text.length < cursor) cursor = 0
46 const end = text.lastIndexOf('\n') + 1
47 cursors.set(session, Math.max(end, cursor))
48 return { lines: end > cursor ? text.slice(cursor, end).split('\n').filter(Boolean) : [], isFirst }
49}
50
51async function poll($: EngineInterface, session: string) {
52 const { lines, isFirst } = await readNew($, session)
53 const records: BridgeRecord[] = []
54 for (const line of lines) {
55 try {
56 const parsed: unknown = JSON.parse(line)
57 if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) records.push(parsed as BridgeRecord)
58 } catch {
59 // A corrupt line is skipped.
60 }
61 }
62 for (const record of isFirst ? lastOfEach(records) : records) await deliver(record)
63}
64
65// After a reload the file holds rows delivered before it, so only the last of each event is state worth replaying.
66export function lastOfEach(records: BridgeRecord[]) {
67 const last = new Map(records.map(record => [String(record.hook_event_name ?? ''), record]))
68 return records.filter(record => last.get(String(record.hook_event_name ?? '')) === record)
69}
70
71async function tick($: EngineInterface) {
72 if (isTicking) return
73 isTicking = true
74 try {
75 // Asked every tick, so a /clear or a resume reads the new session's file.
76 await poll($, String(await $.session.id()))
77 } catch {
78 // The next tick tries again.
79 } finally {
80 isTicking = false
81 }
82}
83
84// Starts the poll once; `mod` names the mod's folder under mods-data.
85export async function startBridge($: EngineInterface, mod: string) {
86 if (isStarted) return
87 isStarted = true
88 try {
89 folder = await folderOf($, mod)
90 if (!folder) return
91 $.clock.every(POLL_MS, () => void tick($))
92 await tick($)
93 } catch {
94 // Without the bridge the mod keeps to what it finds on its own.
95 }
96}
97
98// A session ended: what its file still holds is delivered; the SessionEnd command hook deletes the file.
99export async function endBridge($: EngineInterface, session: string) {
100 if (!folder || !session) return
101 try {
102 await poll($, session)
103 } catch {
104 // The next read of the file delivers it.
105 }
106}
107hooks/levels.ts 42 lines1// mod-model-effort / hooks/levels.ts, by v-amorim
2// Lists the effort levels and their glyphs and colors, matching the status line.
3import style from './style'
4
5export const LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
6
7// Matches the oh-my-posh status line's glyphs and colors, so both read alike.
8export const LEVEL_LOOK: Record<string, { glyph: string; color: string }> = style.levels
9
10// The model menu lists the models requests have used, newest first; no API lists them, and a fixed list goes stale.
11export const MAX_MODELS = 6
12
13// `claude-opus-5-5[1m]` is one model with `claude-opus-5-5`, so the menu keeps one entry; on a model without 1M by default, a pick runs at its default window.
14export const plainModel = (id: string) => id.replace(/\[1m\]$/i, '')
15
16export const withModel = (models: unknown, id: string | undefined) =>
17 [...new Set([...(id ? [id] : []), ...(Array.isArray(models) ? models.filter((model): model is string => typeof model === 'string') : [])].map(plainModel))].slice(0, MAX_MODELS)
18
19// `claude-opus-5-5` reads `Opus 5.5`; a trailing date and a `[1m]` are dropped.
20export const displayName = (model: string) => {
21 const match = /^claude-([a-z]+)-(\d+(?:-\d{1,2})*)(?:-\d{8})?$/.exec(plainModel(model))
22 if (!match) return model
23 return `${match[1]![0]!.toUpperCase()}${match[1]!.slice(1)} ${match[2]!.replace(/-/g, '.')}`
24}
25
26// Sets `modelSettings[model].effortLevel`, the key Alt+P writes; null when the file is not JSON or the edit would touch anything else.
27export function withEffort(text: string, model: string, effort: string): string | null {
28 let before: Record<string, any>
29 try {
30 before = JSON.parse(text)
31 } catch {
32 return null
33 }
34 const after = JSON.parse(text)
35 after.modelSettings = { ...after.modelSettings, [model]: { ...after.modelSettings?.[model], effortLevel: effort } }
36 const indent = /^\{\r?\n([ \t]+)"/.exec(text)?.[1] ?? ' '
37 const out = JSON.stringify(after, null, indent) + '\n'
38 const reparsed = JSON.parse(out)
39 const others = (value: Record<string, any>) => JSON.stringify({ ...value, modelSettings: undefined })
40 return others(before) === others(reparsed) ? out : null
41}
42hooks/entry.tsx 241 lines1// hooks/entry.tsx, by v-amorim
2// Shared pill entry; every mod that draws a menu entry carries the same copy.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import style from './style'
6import { CAPS, mix, THEME, WASH } from './theme'
7
8// color: open text (a red Run); idle: closed text, muted unless set; isBusy shimmers the label until the click's result lands.
9// still: the mod's animations setting is off, so nothing shimmers and the hover color switches at once.
10// lead: a bare glyph before the label in leadColor (after a space, none when tight: the cap is the gap); subLead: an icon before it as a rounded subpill in subLeadColor;
11// badge: a count after it in its own pill. Subpills are capped where `nerd` has the caps.
12// tight: no label padding; tip: posts `tip` instead of `menu-hover`; rail: a divider (a Text between Clients squeezes them); rule: a full-width hairline.
13// spinner: the braille dots lead the label in accent while work runs (write the label without its …); still holds the first frame.
14export type EntryProps = {
15 id: string
16 label: string
17 isOpen: boolean
18 bg: string
19 color?: string
20 idle?: string
21 isBusy?: boolean
22 still?: boolean
23 lead?: string
24 leadColor?: string
25 subLead?: string
26 subLeadColor?: string
27 badge?: { text: string; color: string; bg: string }
28 nerd?: boolean
29 tight?: boolean
30 tip?: Tip
31 rail?: boolean
32 rule?: boolean
33 spinner?: boolean
34 compact?: boolean // false: the pill row may cut the entry to its lead when the row is wider than the band
35}
36
37const entryText = (props: EntryProps) => {
38 const pad = props.tight ? (props.isOpen && !props.nerd ? ' ' : '') : ' '
39 return `${pad}${props.compact && props.lead ? '' : props.label}${props.tight && props.badge ? ' ' : pad}`
40}
41
42// The cells an entry takes; subpills add their two caps, or their two padding spaces in safe mode.
43export const entryCells = (props: EntryProps) => {
44 if (props.rail) return 1
45 const cells = (text: string) => [...text].length
46 const leadText = props.lead ?? (props.spinner ? SPIN_FRAMES[0]! : '')
47 const lead = leadText ? cells(leadText) + (props.tight ? 0 : 1) : 0
48 return lead + (props.subLead ? cells(props.subLead) + 2 : 0) + cells(entryText(props)) + (props.badge ? cells(props.badge.text) + 2 : 0)
49}
50
51// A hovered control's glyph, label and what it does; the pane footer and the band's tip row both show it.
52export type Tip = { glyph: string; label: string; text: string }
53
54// Posted to the owning mod's ui.message when the pointer enters a control (id set) or leaves it (id null).
55export type TipMessage = { type: 'tip'; id: string | null; glyph?: string; label?: string; text?: string }
56
57export const isTipMessage = (data: unknown): data is TipMessage => (data as TipMessage | undefined)?.type === 'tip'
58
59// The band's tip row text for a tip message: "Label: what it does", or '' once the pointer left.
60export const tipText = (message: TipMessage | null | undefined) => (message?.id && message.label ? `${message.label}: ${message.text ?? ''}` : '')
61
62// A control calls this from onPointer, with its tip on enter and null on leave; never also `menu-hover`, one post per frame lands.
63export const postTip = (surface: { post: (data: any) => void }, id: string, tip: Tip | null) => {
64 surface.post((tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }) satisfies TipMessage)
65}
66
67// menu-hover tells the owning mod the pointer arrived or left, so it can show its tip under the row.
68export type EntryMessage = { type: 'menu-entry'; id: string } | { type: 'menu-hover'; id: string; isOver: boolean }
69
70// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
71const HOVER_QUIET_MS = 5000
72
73export const TICK_MS = 140
74
75// The loading shimmer: a soft primary-70 band sweeps the dim text, then a pause.
76const BAND = style.colors.shimmer
77const SWEEP_PAUSE = 8
78
79export const shimmerColor = (at: number, phase: number, length: number) => {
80 const period = length + BAND.length * 2 + SWEEP_PAUSE
81 const head = (phase % period) - BAND.length
82 return BAND[Math.abs(at - head)] ?? style.colors.pills.readout
83}
84
85// The braille dots spinner, one frame every SPIN_MS, in accent.
86export const SPIN_FRAMES = [...style.marks.spinner]
87export const SPIN_MS = 80
88
89// The pointer lights a control at once and its color fades out over this many steps, one every HOVER_TICK_MS.
90export const HOVER_STEPS = 2
91
92export const HOVER_TICK_MS = 60
93
94export { mix }
95
96// One step of a hover fade: full at once while the pointer is on, one step toward 0 once it left.
97export const stepToward = (level: number, isOver: boolean) => (isOver ? HOVER_STEPS : Math.max(0, level - 1))
98
99// Even steps: an eased last step barely moves the color, so the fade seemed to hang at its end.
100export const hoverMix = (from: string, to: string, level: number) => mix(from, to, level / HOVER_STEPS)
101
102// A click shimmers the entry until the mod answers with new props, or this long at most.
103const PRESS_MS = 1200
104
105// pressed: the props a click saw and when it came, cleared once the mod answers.
106type State = { isOver: boolean; at: number; phase: number; level?: number; spin?: number; pressed?: { props: string; at: number } }
107
108const isPressed = (state: State | undefined, props: EntryProps | undefined) =>
109 Boolean(state?.pressed && Date.now() - state.pressed.at < PRESS_MS && state.pressed.props === JSON.stringify(props))
110
111// Each instance's running fade-out timer, started by the leave event and stopped at 0 or when the pointer returns.
112const fades = new WeakMap<object, () => void>()
113
114const startFade = (surface: ClientSurface<State>) => {
115 if (fades.has(surface)) return
116 const stop = surface.every(HOVER_TICK_MS, () => {
117 const { state } = surface
118 if (!state) return
119 const level = stepToward(state.level ?? 0, state.isOver)
120 if (level !== (state.level ?? 0)) surface.setState({ ...state, level })
121 if (state.isOver || level === 0) {
122 stop()
123 fades.delete(surface)
124 }
125 })
126 fades.set(surface, stop)
127}
128
129// The ticker reads the latest props from here, since its closure keeps the first ones.
130const latest = new WeakMap<object, EntryProps>()
131
132// Each instance's running tick: it runs only while a shimmer moves or a hover is watched, so idle pills cost no wakeups.
133const ticks = new WeakMap<object, () => void>()
134
135const startTick = (surface: ClientSurface<State>) => {
136 if (ticks.has(surface)) return
137 const stop = surface.every(TICK_MS, () => {
138 const { state } = surface
139 if (!state) return
140 const now = latest.get(surface)
141 if (!now?.still && (now?.isBusy || isPressed(state, now))) surface.setState({ ...state, phase: state.phase + 1 })
142 else if (state.pressed) surface.setState({ ...state, pressed: undefined })
143 else if (state.isOver && Date.now() - state.at >= HOVER_QUIET_MS) {
144 surface.setState({ ...state, isOver: false, at: Date.now() })
145 if (!now?.still) startFade(surface)
146 const id = now?.id ?? ''
147 if (now?.tip) postTip(surface, id, null)
148 else surface.post({ type: 'menu-hover', id, isOver: false } satisfies EntryMessage)
149 } else if (!state.isOver) {
150 stop()
151 ticks.delete(surface)
152 }
153 })
154 ticks.set(surface, stop)
155}
156
157// Each spinning instance's frame clock, stopped once the entry stops spinning.
158const spins = new WeakMap<object, () => void>()
159
160const startSpin = (surface: ClientSurface<State>) => {
161 if (spins.has(surface)) return
162 const stop = surface.every(SPIN_MS, () => {
163 const { state } = surface
164 const now = latest.get(surface)
165 if (!state || !now?.spinner || now.still) {
166 stop()
167 spins.delete(surface)
168 return
169 }
170 surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
171 })
172 spins.set(surface, stop)
173}
174
175// A Button inverts under the pointer, so the entry is plain text that lights up like a sidebar row.
176const Entry: ClientModule<EntryProps, State> = (props, surface) => {
177 const { Box, Text } = surface.elements
178 if (props.rail) return <Text color={style.colors.pills.separator} backgroundColor={props.bg}>{style.marks.rail}</Text>
179 if (props.rule) return <Text color={THEME.border} backgroundColor={props.bg}>{style.marks.rule.repeat(Math.max(1, surface.columns))}</Text>
180 const hovered = surface.state?.isOver === true
181 latest.set(surface, props)
182
183 if (!surface.state) surface.setState({ isOver: false, at: 0, phase: 0 })
184 if (props.isBusy && !props.still) startTick(surface)
185 if (props.spinner && !props.still) startSpin(surface)
186
187 surface.onPointer(event => {
188 startTick(surface)
189 const over = event.type !== 'leave'
190 if (over !== hovered) {
191 if (props.tip) postTip(surface, props.id, over ? props.tip : null)
192 else surface.post({ type: 'menu-hover', id: props.id, isOver: over } satisfies EntryMessage)
193 }
194 const isClick = event.type === 'down' && event.button === 'left'
195 const base = surface.state ?? { phase: 0, isOver: false, at: 0 }
196 const level = over === base.isOver ? base.level : over ? HOVER_STEPS : props.still ? 0 : base.level
197 if (!over && !props.still) startFade(surface)
198 surface.setState({ ...base, isOver: over, at: Date.now(), level, pressed: isClick ? { props: JSON.stringify(props), at: Date.now() } : base.pressed })
199 if (isClick) surface.post({ type: 'menu-entry', id: props.id } satisfies EntryMessage)
200 })
201
202 const text = entryText(props)
203 const isShimmering = !props.still && (props.isBusy || isPressed(surface.state, props))
204 const phase = surface.state?.phase ?? 0
205 const label = isShimmering ? (
206 <Box key="label" flexDirection="row">
207 {[...text].map((char, at) => (
208 <Text key={`c${at}`} color={shimmerColor(at, phase, text.length)} backgroundColor={props.bg}>
209 {char}
210 </Text>
211 ))}
212 </Box>
213 ) : (
214 <Text key="label" color={hoverMix(props.isOpen ? (props.color ?? THEME.onSurface) : (props.idle ?? THEME.muted), THEME.accent, surface.state?.level ?? 0)} bold={props.isOpen && Boolean(props.idle)} backgroundColor={props.bg}>
215 {text.split(style.marks.sep).flatMap((part, at) => (at === 0 ? [part] : [<Text key={`sep-${at}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
216 </Text>
217 )
218 const lead = props.spinner ? SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length] : props.lead
219 if (!lead && !props.subLead && !props.badge) return isShimmering ? <Box flexDirection="row">{label}</Box> : label
220
221 const sub = (key: string, text: string, color: string, bg: string) => (
222 <Box key={key} flexDirection="row">
223 {props.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={props.bg}>{CAPS.left}</Text> : null}
224 <Text key={`${key}-text`} color={color} backgroundColor={bg} bold>{props.nerd ? text : ` ${text} `}</Text>
225 {props.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={props.bg}>{CAPS.right}</Text> : null}
226 </Box>
227 )
228 const subHue = props.subLeadColor ?? THEME.accent
229 const { badge } = props
230 return (
231 <Box flexDirection="row">
232 {lead ? <Text key="lead" color={props.spinner ? THEME.accent : (props.leadColor ?? THEME.accent)} backgroundColor={props.bg}>{props.tight ? lead : ` ${lead}`}</Text> : null}
233 {props.subLead ? sub('sub-lead', props.subLead, subHue, mix(subHue, props.bg, WASH)) : null}
234 {label}
235 {badge ? sub('badge', badge.text, badge.color, badge.bg) : null}
236 </Box>
237 )
238}
239
240export default Entry
241hooks/look.tsx 279 lines1// hooks/look.tsx, by v-amorim
2// Shared look; every mod carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import style from './style'
6import type { Tip } from './entry'
7import { CAPS, decodeAll, MARK, mix, THEME, WASH } from './theme'
8
9type Glyphs = Omit<typeof style.glyphs.nerd, 'icon' | 'mark'> & { icon: Record<string, string>; mark: Record<string, string> }
10
11// Safe mode: common Unicode that every modern terminal font draws. Headings and rows carry no icon.
12const SAFE: Glyphs = decodeAll(style.glyphs.safe)
13
14// Nerd mode: Font Awesome, Material and VS Code glyphs from any Nerd Font, controls padded to the same three cells.
15const NERD: Glyphs = decodeAll(style.glyphs.nerd)
16
17export type Look = { nerd: boolean; glyph: typeof SAFE }
18
19export const lookFor = (nerd: boolean): Look => ({ nerd, glyph: nerd ? NERD : SAFE })
20
21// The icons setting every drawing mod declares in its userConfig.
22export type IconMode = 'auto' | 'nerd' | 'safe'
23
24export const terminalSettings = (localAppData: string) => [
25 `${localAppData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
26 `${localAppData}/Microsoft/Windows Terminal/settings.json`,
27]
28
29// Matches "Fira Code Nerd Font Mono" and the patched suffixes NF, NFM and NFP, as in "FiraCodeNFM-Reg".
30export const isNerdName = (face: string) => /nerd|\bNFM?\b/i.test(face) || /NF[MP]?(?![A-Za-z])/.test(face)
31
32export const isNerdFace = (settingsText: string) => [...settingsText.matchAll(/"face"\s*:\s*"([^"]*)"/g)].some(match => isNerdName(match[1]!))
33
34// The variables that name the terminal: WT_SESSION from Windows Terminal, TERM_PROGRAM from programs such as mintty and iTerm2.
35export type TerminalEnv = Partial<
36 Record<'WT_SESSION' | 'TERM_PROGRAM' | 'ITERM_PROFILE' | 'KITTY_WINDOW_ID' | 'ALACRITTY_WINDOW_ID' | 'GNOME_TERMINAL_SCREEN' | 'KONSOLE_VERSION', string | undefined>
37>
38
39// What detection reads: files by path, and the stdout of a command that exits 0 (it throws otherwise).
40export type Host = { localAppData: string; home: string; read: (path: string) => Promise<string>; run: (argv: string[]) => Promise<string> }
41
42// mintty inherits WT_SESSION from a Windows Terminal tab, so only an unset or Windows Terminal TERM_PROGRAM counts.
43export const isWindowsTerminal = ({ WT_SESSION, TERM_PROGRAM }: TerminalEnv) =>
44 Boolean(WT_SESSION) && (!TERM_PROGRAM || /windows\s*terminal/i.test(TERM_PROGRAM))
45
46// Terminals whose font sits in a text config: the first file that names a font wins, and within a file the first pattern.
47const CONFIG_TERMINALS: { is: (env: TerminalEnv) => boolean; files: (host: Host) => string[]; faces: RegExp[] }[] = [
48 {
49 is: env => env.TERM_PROGRAM === 'vscode',
50 files: ({ home, localAppData }) => [
51 `${home}/Library/Application Support/Code/User/settings.json`,
52 `${home}/.config/Code/User/settings.json`,
53 `${localAppData.replace(/\/Local$/, '/Roaming')}/Code/User/settings.json`,
54 ],
55 faces: [/"terminal\.integrated\.fontFamily"\s*:\s*"([^"]*)"/, /"editor\.fontFamily"\s*:\s*"([^"]*)"/],
56 },
57 {
58 is: env => env.TERM_PROGRAM === 'ghostty',
59 files: ({ home }) => [`${home}/.config/ghostty/config`, `${home}/Library/Application Support/com.mitchellh.ghostty/config`],
60 faces: [/^\s*font-family\s*=\s*"?([^"\n#]+)/m],
61 },
62 {
63 is: env => Boolean(env.KITTY_WINDOW_ID),
64 files: ({ home }) => [`${home}/.config/kitty/kitty.conf`],
65 faces: [/^\s*font_family\s+(.+)$/m],
66 },
67 {
68 is: env => env.TERM_PROGRAM === 'WezTerm',
69 files: ({ home }) => [`${home}/.wezterm.lua`, `${home}/.config/wezterm/wezterm.lua`],
70 faces: [/wezterm\.font(?:_with_fallback)?\(\s*\{?\s*["']([^"']+)["']/],
71 },
72 {
73 is: env => Boolean(env.ALACRITTY_WINDOW_ID),
74 files: ({ home }) => [`${home}/.config/alacritty/alacritty.toml`],
75 faces: [/\[font\.normal\][^[]*?family\s*=\s*"([^"]+)"/],
76 },
77]
78
79// iTerm2 keeps its profiles in a plist; plutil prints the part asked for. The profile is the one this window runs, else the default.
80async function itermFace(env: TerminalEnv, host: Host) {
81 const plist = `${host.home}/Library/Preferences/com.googlecode.iterm2.plist`
82 const profiles: { Name?: string; Guid?: string; 'Normal Font'?: string }[] = JSON.parse(await host.run(['plutil', '-extract', 'New Bookmarks', 'json', '-o', '-', plist]))
83 const named = profiles.find(profile => profile.Name === env.ITERM_PROFILE)
84 if (named) return named['Normal Font']
85 const guid = (await host.run(['plutil', '-extract', 'Default Bookmark Guid', 'raw', '-o', '-', plist])).trim()
86 return profiles.find(profile => profile.Guid === guid)?.['Normal Font']
87}
88
89// GNOME Terminal keeps the default profile's font in dconf, and ignores it while the profile uses the system font.
90async function gnomeFace(host: Host) {
91 const unquote = (text: string) => text.trim().replace(/^'|'$/g, '')
92 const schema = `org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:${unquote(await host.run(['gsettings', 'get', 'org.gnome.Terminal.ProfilesList', 'default']))}/`
93 if (unquote(await host.run(['gsettings', 'get', schema, 'use-system-font'])) === 'true') return undefined
94 return unquote(await host.run(['gsettings', 'get', schema, 'font']))
95}
96
97// Konsole names its profile in konsolerc, and the profile file holds `Font=Name,size,...`.
98async function konsoleFace(host: Host) {
99 const profile = /^DefaultProfile=(.+)$/m.exec(await host.read(`${host.home}/.config/konsolerc`))?.[1]?.trim()
100 return profile ? /^Font=([^,\n]+)/m.exec(await host.read(`${host.home}/.local/share/konsole/${profile}`))?.[1] : undefined
101}
102
103async function configFace(terminal: (typeof CONFIG_TERMINALS)[number], host: Host) {
104 for (const path of terminal.files(host)) {
105 const text = await host.read(path).catch(() => '')
106 for (const pattern of terminal.faces) {
107 const face = pattern.exec(text)?.[1]
108 if (face) return face
109 }
110 }
111 return undefined
112}
113
114function terminalFace(env: TerminalEnv, host: Host) {
115 if (env.TERM_PROGRAM === 'iTerm.app') return itermFace(env, host)
116 if (env.GNOME_TERMINAL_SCREEN) return gnomeFace(host)
117 if (env.KONSOLE_VERSION) return konsoleFace(host)
118 const terminal = CONFIG_TERMINALS.find(candidate => candidate.is(env))
119 return terminal ? configFace(terminal, host) : Promise.resolve(undefined)
120}
121
122// Every mod looks up the font on start at once, so a lookup can fail by chance; a third failure throws and the caller keeps its saved answer.
123async function retried<T>(attempt: () => Promise<T>, tries = 3): Promise<T> {
124 for (let left = tries - 1; ; left--) {
125 try {
126 return await attempt()
127 } catch (error) {
128 if (left === 0) throw error
129 }
130 }
131}
132
133// Nerd glyphs only when the terminal this window runs in names a Nerd font; an unknown terminal stays safe, and a lookup that keeps failing throws.
134export async function detectNerd(env: TerminalEnv, host: Host) {
135 if (isWindowsTerminal(env)) {
136 for (const path of terminalSettings(host.localAppData)) {
137 if (isNerdFace(await host.read(path).catch(() => ''))) return true
138 }
139 return false
140 }
141 const face = await retried(() => terminalFace(env, host))
142 return face !== undefined && isNerdName(face)
143}
144
145// `text` led by its icon in Nerd mode; the text alone in safe mode or when the name has no icon.
146export const withIcon = (look: Look, name: string, text: string) => (look.glyph.icon[name] ? `${look.glyph.icon[name]} ${text}` : text)
147
148// A status note with its leading mark (MARK.warn or MARK.ok from theme.ts) drawn as this look's glyph.
149export function withMark(look: Look, note: string) {
150 if (note.startsWith('! ')) return `${look.glyph.warn} ${note.slice(2)}`
151 if (note.startsWith('✓ ')) return `${look.glyph.ok} ${note.slice(2)}`
152 return note
153}
154
155type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
156
157// A pane's title line: the name's icon in Nerd mode, the safe mark otherwise.
158export const paneTitle = ({ Text }: Elements, look: Look, icon: string, text: string) => (
159 <Text key="pane-title" color={THEME.accent} bold>{`${look.glyph.icon[icon] ?? look.glyph.title} ${text}`}</Text>
160)
161
162// One line of text in `color` with every separator dot in the separator colour, since a dot in the text's colour reads as part of the words.
163export function sepText({ Text }: Pick<Elements, 'Text'>, text: string, color: string, key: string, props: Record<string, unknown> = {}) {
164 return (
165 <Text key={key} {...props}>
166 {text.split(MARK.sep).flatMap((part, at) => [
167 ...(at > 0 ? [<Text key={`${key}-sep-${at}`} color={style.colors.pills.separator}>{MARK.sep}</Text>] : []),
168 <Text key={`${key}-part-${at}`} color={color}>{part}</Text>,
169 ])}
170 </Text>
171 )
172}
173
174// The footer rows every pane reserves at its bottom: a rule, then the hovered control's glyph, label and what it does.
175export const FOOTER_ROWS = 2
176
177// A Button cannot post a tip, so its tip is drawn hidden and shown by the hover group the Button's pill shares.
178export type Reveal = { scope: string; tip: Tip }
179
180const tipRow = ({ Box, Text }: Elements, key: string, tip: Tip | null, columns: number, extra: Record<string, unknown> = {}) => (
181 <Box key={key} flexDirection="row" paddingX={1} width={columns} height={1} backgroundColor={THEME.bg} {...extra}>
182 {tip ? <Text color={THEME.accent} backgroundColor={THEME.bg}>{tip.glyph}</Text> : null}
183 {tip ? <Box flexShrink={0}><Text color={THEME.onSurface} backgroundColor={THEME.bg}>{` ${tip.label}`}</Text></Box> : null}
184 {tip ? sepText({ Text }, ` ${MARK.sep} ${tip.text}`, THEME.muted, `${key}-text`, { backgroundColor: THEME.bg, wrap: 'truncate-end' }) : <Text backgroundColor={THEME.bg}> </Text>}
185 </Box>
186)
187
188// Pinned at the pane bottom (scroll offset plus body rows) on its own background, so no row shows through; the row under the rule stays blank with no tip, so nothing jumps.
189export function paneFooter(elements: Elements, tip: Tip | null, columns: number, scroll: { offset: number; bodyRows: number }, reveals: Reveal[] = []) {
190 const { Box, Text } = elements
191 return (
192 <Box key="pane-footer" position="absolute" top={Math.max(0, scroll.offset + scroll.bodyRows - FOOTER_ROWS)} left={0} width={columns} height={FOOTER_ROWS} flexDirection="column" backgroundColor={THEME.bg}>
193 <Text key="footer-rule" color={THEME.border} backgroundColor={THEME.bg}>{MARK.rule.repeat(Math.max(1, columns))}</Text>
194 {tipRow(elements, 'footer-tip', tip, columns)}
195 {reveals.map((reveal, at) =>
196 tipRow(elements, `footer-reveal-${at}`, reveal.tip, columns, { position: 'absolute', top: 1, left: 0, display: 'none', hover: { scope: reveal.scope, display: 'flex' } }),
197 )}
198 </Box>
199 )
200}
201
202// A pill's leading icon, mark or digit, or its count: a rounded subpill washed from `hue` into the `fill` it sits on.
203export function subpill({ Box, Text }: Elements, nerd: boolean, fill: string, text: string, key: string, hue: string = THEME.accent) {
204 const bg = mix(hue, fill, WASH)
205 return (
206 <Box key={key} flexDirection="row" flexShrink={0}>
207 {nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={fill}>{CAPS.left}</Text> : null}
208 <Text key={`${key}-text`} color={hue} backgroundColor={bg} bold>{nerd ? text : ` ${text} `}</Text>
209 {nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={fill}>{CAPS.right}</Text> : null}
210 </Box>
211 )
212}
213
214// A section's heading: a surface3 pill led by its icon as a subpill in Nerd mode, bold accent text in safe mode.
215export function heading(elements: Elements, look: Look, key: string, icon: string, title: string) {
216 const { Box, Text } = elements
217 if (!look.nerd) {
218 return (
219 <Box key={`${key}-head`}>
220 <Text color={THEME.accent} bold>{title}</Text>
221 </Box>
222 )
223 }
224 const glyph = look.glyph.icon[icon]
225 return (
226 <Box key={`${key}-head`} flexDirection="row">
227 <Text color={THEME.surface3}>{CAPS.left}</Text>
228 <Box key={`${key}-head-body`} flexDirection="row" backgroundColor={THEME.surface3}>
229 {glyph ? subpill(elements, true, THEME.surface3, glyph, `${key}-head-icon`) : null}
230 <Text color={THEME.onSurface} bold>{glyph ? ` ${title}` : title}</Text>
231 </Box>
232 <Text color={THEME.surface3}>{CAPS.right}</Text>
233 </Box>
234 )
235}
236
237// A card's fill: surface1 washed lightly toward its hue, under a neutral border. Pills on the card sit on it.
238export const cardFill = (hue: string = THEME.accent) => mix(THEME.surface1, hue, 0.06)
239
240// The row above a card, painted on bg so a scrolled card above never shows through the gap.
241export function cardGap({ Box }: Elements, key: string, bg: string = THEME.bg) {
242 return <Box key={`${key}-gap`} height={1} flexShrink={0} backgroundColor={bg} />
243}
244
245// A card's title laid over its top border after the corner (`top` is the border's row); Pane refuses borderTop, so the border stays whole underneath.
246export function titleEdge(elements: Elements, look: Look, key: string, icon: string, title: string, hint = '', top = 1, edge: string = THEME.border) {
247 const { Box, Text } = elements
248 const glyph = look.nerd ? look.glyph.icon[icon] : undefined
249 return (
250 <Box key={`${key}-edge`} position="absolute" top={top} left={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
251 <Text color={edge}>{`${MARK.rule} `}</Text>
252 {glyph ? <Text color={THEME.accent}>{`${glyph} `}</Text> : null}
253 {sepText(elements, title, THEME.onSurface, `${key}-title`, { bold: true })}
254 {hint ? sepText(elements, ` ${hint}`, THEME.muted, `${key}-hint`) : null}
255 <Text> </Text>
256 </Box>
257 )
258}
259
260// A round card tinted toward `hue`, its title (and muted `hint`) set into the top edge, `right` (such as Fold all) at that edge's end.
261export function section(elements: Elements, look: Look, key: string, icon: string, title: string, body: unknown, hue: string = THEME.accent, { hint = '', right }: { hint?: string; right?: unknown } = {}) {
262 const { Box } = elements
263 return (
264 <Box key={`${key}-block`} flexDirection="column">
265 {cardGap(elements, key)}
266 <Box key={key} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(hue)} paddingX={1}>
267 {body}
268 </Box>
269 {title ? titleEdge(elements, look, key, icon, title, hint) : null}
270 {right ? (
271 <Box key={`${key}-edge-right`} position="absolute" top={1} right={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
272 {right}
273 </Box>
274 ) : null}
275 </Box>
276 )
277}
278
279hooks/meter.tsx 120 lines1// mod-model-effort / hooks/meter.tsx, by v-amorim
2// Draws the footer meter with model pill and effort levels, handling hover and clicks.
3import type { ClientModule } from 'claude-code'
4
5import { HOVER_STEPS, HOVER_TICK_MS, hoverMix, postTip, stepToward, type Tip } from './entry'
6import { LEVEL_LOOK, LEVELS } from './levels'
7import style from './style'
8import { CAPS, THEME } from './theme'
9
10// card: the meter drawn inside the effort card, always open and never compact. under: the fill behind its caps there.
11// still: the animations are off, so the levels switch at once. nerd: the meter draws its caps, so a pointer on one keeps the levels open.
12export type MeterProps = { effort: string | null; model: string; bg: string; still?: boolean; nerd?: boolean; tipModel: Tip; tipRest: Tip; tipLevels: Tip[]; cells?: number; compact?: boolean; card?: boolean; under?: string }
13
14// The meter at rest: its caps on Nerd, the model name padded by one, then the current level and its space.
15export const meterCells = (model: string, nerd: boolean) => (nerd ? 2 : 0) + [...model].length + 2 + 2
16
17export type MeterMessage = { type: 'set'; effort: string } | { type: 'model-menu' } | { type: 'effort-card' }
18
19// The readout color inside the model pill.
20const PILL_FG = style.colors.pills.readout
21
22// A dialog opening under the pointer can swallow the leave event, so a hover ends after this long without pointer events.
23const HOVER_QUIET_MS = 5000
24
25// lights: each glyph's fade from dark (0) to its level color (HOVER_STEPS).
26// tipId: the part of the pill whose tip was posted last, null once the pointer left.
27type Hover = { level: number; at: number; lights?: number[]; tipId?: string | null }
28
29const litOf = (props: MeterProps, level: number) => (level >= 0 ? level : props.effort ? LEVELS.indexOf(props.effort as (typeof LEVELS)[number]) : -1)
30
31const targets = (lit: number) => LEVELS.map((_, index) => (index <= lit ? HOVER_STEPS : 0))
32
33// The ticker's closure keeps the first props, so it reads the latest from here.
34const latest = new WeakMap<object, MeterProps>()
35
36// The timer runs while a glyph fades or a hover may need ending, and stops at rest, so an idle meter costs no wakeups.
37const timers = new WeakMap<object, () => void>()
38
39const wake = (surface: Parameters<ClientModule<MeterProps, Hover>>[1]) => {
40 if (timers.has(surface)) return
41 const stop = surface.every(HOVER_TICK_MS, () => {
42 const { state } = surface
43 const now = latest.get(surface)
44 if (!state || !now) return
45 const goal = targets(litOf(now, state.level))
46 const lights = state.lights ?? goal
47 if (lights.some((light, index) => light !== goal[index])) {
48 surface.setState({ ...state, lights: now.still ? goal : lights.map((light, index) => stepToward(light, goal[index]! > 0)) })
49 return
50 }
51 if (state.level === -2) {
52 stop()
53 timers.delete(surface)
54 return
55 }
56 if (Date.now() - state.at < HOVER_QUIET_MS) return
57 surface.setState({ ...state, level: -2, at: Date.now(), tipId: null })
58 postTip(surface, 'effort-meter', null)
59 })
60 timers.set(surface, stop)
61}
62
63// Mouse-only slider: a level glyph and the space after it set that level.
64const Meter: ClientModule<MeterProps, Hover> = (props, surface) => {
65 const { Box, Text } = surface.elements
66 const reached = props.effort ? LEVELS.indexOf(props.effort as (typeof LEVELS)[number]) : -1
67 // A compact meter never opens inline: a click opens the effort card instead.
68 const compact = Boolean(props.compact) && !props.card
69 const head = compact ? ' ' : ` ${props.model} ` // compact keeps one cell before the level, which still opens the model menu
70 const start = (props.nerd ? 1 : 0) + head.length
71
72 // The level under the pointer previews what a click would set: -1 on the model name, -2 off the pill.
73 const hovered = surface.state?.level ?? -2
74 latest.set(surface, props)
75 if (!surface.state) surface.setState({ level: -2, at: 0, lights: targets(reached) })
76 // A new effort from outside moves the lights too.
77 const goalNow = targets(litOf(props, hovered))
78 if ((surface.state?.lights ?? goalNow).some((light, index) => light !== goalNow[index])) wake(surface)
79 const isExpanded = Boolean(props.card) || (!compact && hovered !== -2)
80 surface.onPointer(event => {
81 wake(surface)
82 const slot = event.x - start
83 // While folded the slots are not where they will be, so the first event only opens the meter.
84 const level = event.type === 'leave' ? -2 : isExpanded && slot >= 0 && slot < LEVELS.length * 2 ? Math.floor(slot / 2) : -1
85 const isClick = event.type === 'down' && event.button === 'left'
86 const isModel = !compact && slot < 0
87 const tipId = level === -2 ? null : level >= 0 ? `effort-level-${LEVELS[level]}` : isModel ? 'effort-model' : 'effort-rest'
88 // One post lands per event, so a click posts its own message and the next move posts the tip.
89 if (tipId !== (surface.state?.tipId ?? null) && !isClick) postTip(surface, tipId ?? 'effort-meter', tipId === null ? null : level >= 0 ? props.tipLevels[level]! : isModel ? props.tipModel : props.tipRest)
90 const lights = surface.state?.lights ?? targets(reached)
91 // The first fade step lands with the pointer, so the meter answers at once.
92 const stepped = targets(litOf(props, level)).map((goal, index) => stepToward(lights[index] ?? 0, goal > 0))
93 surface.setState({ ...surface.state, level, at: Date.now(), tipId: isClick ? (surface.state?.tipId ?? null) : tipId, lights: props.still ? targets(litOf(props, level)) : level !== hovered ? stepped : lights })
94 if (isClick && compact) {
95 surface.post({ type: 'effort-card' } satisfies MeterMessage)
96 return
97 }
98 if (isClick && level >= 0) surface.post({ type: 'set', effort: LEVELS[level]! } satisfies MeterMessage)
99 if (isClick && level < 0 && isModel) surface.post({ type: 'model-menu' } satisfies MeterMessage)
100 })
101 const lights = surface.state?.lights ?? targets(litOf(props, hovered))
102
103 return (
104 <Box flexDirection="row">
105 {props.nerd ? <Text key="cap-left" color={props.bg} backgroundColor={props.under}>{CAPS.left}</Text> : null}
106 <Text key="head" color={PILL_FG} backgroundColor={props.bg}>{head}</Text>
107 {LEVELS.map((level, index) =>
108 isExpanded || index === Math.max(reached, 0) ? (
109 <Text key={level} color={hoverMix(THEME.border, LEVEL_LOOK[level]!.color, lights[index] ?? 0)} bold={index === hovered} backgroundColor={props.bg}>
110 {`${LEVEL_LOOK[level]!.glyph} `}
111 </Text>
112 ) : null,
113 )}
114 {props.nerd ? <Text key="cap-right" color={props.bg} backgroundColor={props.under}>{CAPS.right}</Text> : null}
115 </Box>
116 )
117}
118
119export default Meter
120hooks/settings.tsx 297 lines1// hooks/settings.tsx, by v-amorim
2// Shared settings body; every mod carries the same copy.
3import type { ConfigRow, ConfigValue, RenderElement } from 'claude-code'
4
5import type { EntryProps, Tip } from './entry'
6import { cardFill, cardGap, section, sepText, type Look, type Reveal } from './look'
7import style from './style'
8import { CAPS, fill, MARK, THEME } from './theme'
9
10// A button a mod offers beside its settings: runs `/<command> <args>`, declared in its showcase.json.
11// isLocal marks the drawing mod's own command, which it calls directly, so Claude working does not hold it.
12export type Action = { label: string; command: string; args?: string; isLocal?: boolean }
13
14type Elements = { Box: (props: any) => RenderElement; Button: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
15
16export type SettingsProps = {
17 // The mod's own /config rows (`<plugin>.<field>`), as $.config.list() returns them.
18 rows: ConfigRow[]
19 actions: Action[]
20 // Another mod's command run while Claude works only queues as a prompt, so those actions go inert.
21 working: boolean
22 run: (action: Action) => unknown
23 // The animations setting is off: a clicked control does not shimmer.
24 still?: boolean
25 // Hover tips by setting field (the key after the dot); a setting without one shows its description's first sentence.
26 tips?: Record<string, string>
27 // The mod's example cards, from its showcase.json, so its own panel and mod-manager draw the same ones.
28 previews?: Previews
29 // The mod's plugin.json userConfig, read by its own panel and by mod-manager alike, for the min and max a number setting declares.
30 fields?: Record<string, Range>
31 // Text settings (by field) another mod edits in its own panel: the card shows a pill running that panel's command in place of "edit it in /config".
32 editors?: Record<string, Action>
33}
34
35export type Range = { min?: number; max?: number }
36
37// A title's trailing "(unit)" is what its number reads in; the head drops it and the value carries it.
38export function splitTitle(label: string) {
39 const match = /^(.*?)\s*\(([^()]+)\)$/.exec(label)
40 return match ? { name: match[1]!, unit: match[2]! } : { name: label, unit: '' }
41}
42
43// Large round counts read short, in thousands or millions.
44const short = (n: number) => (n >= 1_000_000 && n % 100_000 === 0 ? `${n / 1_000_000}M` : n >= 10_000 && n % 1000 === 0 ? `${n / 1000}K` : String(n))
45
46export function showNumber(value: number, unit: string) {
47 if (!unit) return String(value)
48 if (unit === '%') return `${value}%`
49 return `${short(value)} ${value === 1 ? unit.replace(/s$/, '') : unit}`
50}
51
52// About twenty clicks from one end of the range to the other, on a 1, 2 or 5 step; no range steps by one.
53export function stepOf({ min, max }: Range = {}) {
54 if (min === undefined || max === undefined || max <= min) return 1
55 const raw = (max - min) / 20
56 const power = 10 ** Math.floor(Math.log10(raw))
57 const nearest = [1, 2, 5, 10].map(factor => factor * power).reduce((best, step) => (Math.abs(step - raw) < Math.abs(best - raw) ? step : best))
58 return Math.max(1, nearest)
59}
60
61export function stepNumber(value: number, by: 1 | -1, range: Range = {}) {
62 return Math.min(range.max ?? Infinity, Math.max(range.min ?? -Infinity, value + by * stepOf(range)))
63}
64
65type ThemeColor = keyof typeof THEME
66
67// A literal run of an example line, as the mod's previews test drew it from its real tree; a color is a theme name or a #hex.
68export type PreviewSpan = { text: string; color?: string; bg?: string; bold?: boolean }
69export type PreviewLine = { spans: PreviewSpan[] }
70export type Preview = { frame?: { border: string; bg: string }; nerd: PreviewLine[]; safe: PreviewLine[] }
71
72// showcase.json's "previews", written by `just previews`: per setting field, per value as text ("true", "false", an option), the card in each icon mode.
73export type Previews = Record<string, Record<string, Preview>>
74
75const shade = (color: string | undefined) => (color === undefined ? undefined : color in THEME ? THEME[color as ThemeColor] : color)
76
77// What the row's current value looks like, as a card under it; null when the mod drew none for that value.
78function exampleOf({ Box, Text }: Elements, look: Look, previews: Previews | undefined, row: ConfigRow, index: number) {
79 const entry = previews?.[row.key.slice(row.key.indexOf('.') + 1)]?.[String(row.value)]
80 const lines = look.nerd ? entry?.nerd : entry?.safe
81 if (!entry || !lines?.length) return null
82 // The lines carry their own padding and strip, so the frame adds none. The example sinks to bg, so it never matches the card it sits in.
83 const sunk = (color: string | undefined) => (color !== undefined && color === shade(entry.frame?.bg) ? THEME.bg : color)
84 return (
85 <Box key={`setting-preview-${index}`} flexDirection="column" borderStyle="round" borderColor={shade(entry.frame?.border) ?? THEME.border} backgroundColor={THEME.bg} marginTop={1}>
86 {lines.map((line, index) => (
87 <Text key={`line-${index}`} wrap="truncate-end">
88 {line.spans.length === 0 ? ' ' : line.spans.map((span, at) => <Text key={`span-${at}`} color={shade(span.color) ?? THEME.onSurface} {...(span.bg ? { backgroundColor: sunk(shade(span.bg)) } : {})} {...(span.bold ? { bold: true } : {})}>{span.text}</Text>)}
89 </Text>
90 ))}
91 </Box>
92 )
93}
94
95// The value as the panel shows it: on/off as words, a list as its items.
96const shown = (row: ConfigRow) =>
97 row.kind === 'boolean' ? (row.value === true ? style.text.on : style.text.off) : Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
98
99// Words that stay capitalized when a description follows a tip's label and colon.
100const PROPER = new Set(['Claude', 'Haiku', 'Opus', 'Sonnet', 'Windows', 'Nerd'])
101
102// A setting's description as the tip after "Label: ": its first sentence, led by a lowercase letter unless that word is a proper noun.
103export function descriptionTip(description: string) {
104 const sentence = description.split('. ')[0]!.replace(/\.$/, '')
105 const word = /^[A-Z][a-z]+\b/.exec(sentence)?.[0]
106 return word && !PROPER.has(word) ? sentence[0]!.toLowerCase() + sentence.slice(1) : sentence
107}
108
109// A control is an Entry pill posting `setting:<key>=<value as JSON>`; the mod's ui.message hook saves it with settingChoice.
110const SETTING = 'setting:'
111const settingId = (row: ConfigRow, value: ConfigValue) => `${SETTING}${row.key}=${JSON.stringify(value)}`
112
113// The change a clicked settings control asks for, or null when the message is something else.
114export function settingChoice(data: unknown): { key: string; value: ConfigValue } | null {
115 const message = data as { type?: string; id?: string } | undefined
116 if (message?.type !== 'menu-entry' || !message.id?.startsWith(SETTING)) return null
117 const rest = message.id.slice(SETTING.length)
118 const at = rest.indexOf('=')
119 if (at === -1) return null
120 try {
121 return { key: rest.slice(0, at), value: JSON.parse(rest.slice(at + 1)) as ConfigValue }
122 } catch {
123 return null
124 }
125}
126
127// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[↻]`).
128const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
129
130// A pill on the card's tint, capped where the font has the caps; `hover` lights the caps with a Button scoped to the pill's key.
131const pillOn = ({ Box, Text }: Elements, look: Look, key: string, bg: string, body: unknown, on = THEME.surface1, hover?: string) => {
132 const lit = hover ? { hover: { scope: key, color: hover } } : {}
133 return (
134 <Box key={key} flexDirection="row" flexShrink={0}>
135 {look.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={on} {...lit}>{CAPS.left}</Text> : null}
136 {body}
137 {look.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={on} {...lit}>{CAPS.right}</Text> : null}
138 </Box>
139 )
140}
141
142export type Choice = { key: string; isOn: boolean; props: Pick<EntryProps, 'id' | 'label' | 'still' | 'tip' | 'subLead' | 'subLeadColor'> }
143
144// A control track: a surface3 pill on `on`, padded by a space each side in safe mode, where it has no caps.
145function track(elements: Elements, look: Look, key: string, on: string, parts: unknown[]) {
146 const { Box, Text } = elements
147 const space = (side: string) => (look.nerd ? null : <Text key={`${key}-space-${side}`} backgroundColor={THEME.surface3}> </Text>)
148 return pillOn(elements, look, `${key}-bar`, THEME.surface3, <Box key={`${key}-options`} flexDirection="row" backgroundColor={THEME.surface3}>{[space('left'), ...parts, space('right')]}</Box>, on)
149}
150
151// A choice in a track: the pick a lifted (surface-4) subpill in primary-70 (`ok`), the rest muted; `on` is what the track sits on.
152export function choiceBar(elements: Elements, look: Look, key: string, on: string, choices: Choice[]) {
153 const { Client } = elements
154 const entry = (choice: Choice, shape: Partial<EntryProps>) => (
155 <Client key={choice.key} module="./entry.tsx" height={1} props={{ ...choice.props, isOpen: choice.isOn, bg: THEME.surface3, ...shape } satisfies EntryProps} />
156 )
157 const options = choices.map(choice =>
158 choice.isOn
159 ? // Tight, so the subpill is as wide as an idle option: the caps (Nerd) or a space (safe) stand where its padding was.
160 pillOn(elements, look, `${choice.key}-chosen`, THEME.lifted, entry(choice, { bg: THEME.lifted, color: THEME.ok, idle: THEME.muted, nerd: look.nerd, tight: true }), THEME.surface3)
161 : entry(choice, { idle: THEME.muted }),
162 )
163 return track(elements, look, key, on, options)
164}
165
166const actionScope = (index: number) => `action-row-${index}`
167
168// The tip each live action shows in the pane footer on hover; a Button cannot post one, so paneFooter reveals it by the pill's hover group.
169export const actionReveals = (actions: Action[], look: Look, working: boolean): Reveal[] =>
170 actions.flatMap((action, index) =>
171 working && !action.isLocal ? [] : [{ scope: actionScope(index), tip: { glyph: bare(look.glyph.go), label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }],
172 )
173
174const editorScope = (index: number) => `editor-row-${index}`
175
176const fieldOf = (row: ConfigRow) => row.key.slice(row.key.indexOf('.') + 1)
177
178// The footer tip of each live editor pill, like actionReveals.
179export const editorReveals = (rows: ConfigRow[], editors: Record<string, Action>, look: Look, working: boolean): Reveal[] =>
180 rows.flatMap((row, index) => {
181 const action = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
182 return action && !working ? [{ scope: editorScope(index), tip: { glyph: MARK.open, label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }] : []
183 })
184
185// A pill with a plain Button so the press is a real one; the glyph sits in the label so the whole pill lights.
186const buttonPill = (elements: Elements, look: Look, scope: string, key: string, glyph: string, label: string, onPress: () => unknown) => {
187 const { Button } = elements
188 return pillOn(
189 elements,
190 look,
191 scope,
192 THEME.surface3,
193 <Button key={key} label={look.nerd ? `${glyph} ${label}` : ` ${glyph} ${label} `} plain hover={{ scope, color: THEME.accent, inverse: true }} onPress={onPress} />,
194 THEME.surface1,
195 THEME.accent,
196 )
197}
198
199// Edits the row in place: on/off flips, a choice is a bar, a number steps; text is edited in /config.
200function control(elements: Elements, look: Look, row: ConfigRow, index: number, still: boolean, tips?: Record<string, string>, fields?: Record<string, Range>) {
201 const { Client, Text } = elements
202 const key = `setting-${index}`
203 const text = tips?.[row.key.slice(row.key.indexOf('.') + 1)] ?? (row.description ? descriptionTip(row.description) : '')
204 // Client props refuse an undefined value, so no tip text means no key.
205 const tipFor = (glyph: string) => (text ? { tip: { glyph: bare(glyph), label: splitTitle(row.label).name, text } satisfies Tip } : {})
206 const entry = (suffix: string, label: string, value: ConfigValue, bg: string, color: string, isOpen: boolean, glyph = look.glyph.cycle) => (
207 <Client key={`${key}-${suffix}`} module="./entry.tsx" height={1} props={{ id: settingId(row, value), label, isOpen, bg, color, idle: color, still, ...tipFor(glyph) } satisfies EntryProps} />
208 )
209 if (row.isLocked) return <Text color={THEME.muted}>{`${shown(row)} ${bare(look.glyph.locked)} ${style.text.locked}`}</Text>
210 if (row.kind === 'boolean') {
211 // The switch: `━━●` on, `●━━` off, the knob a bare glyph joined to the line.
212 const isOn = row.value === true
213 const shape = isOn
214 ? { lead: MARK.track, leadColor: THEME.accent, label: MARK.knob, idle: THEME.ok }
215 : { lead: MARK.knob, leadColor: style.colors.pills.readout, label: MARK.track, idle: THEME.trackOff }
216 const flip = <Client key={`${key}-flip`} module="./entry.tsx" height={1} props={{ id: settingId(row, !isOn), isOpen: false, bg: THEME.surface3, tight: true, still, ...shape, ...tipFor(look.glyph.cycle) } satisfies EntryProps} />
217 return track(elements, look, key, cardFill(), [flip])
218 }
219 if (row.kind === 'choice' && row.options?.length) {
220 const choices = row.options.map((option, at) => ({ key: `${key}-option-${at}`, isOn: option === String(row.value), props: { id: settingId(row, option), label: option, still, ...tipFor(look.glyph.cycle) } }))
221 return choiceBar(elements, look, key, cardFill(), choices)
222 }
223 if (row.kind === 'number' && typeof row.value === 'number') {
224 const range = fields?.[row.key.slice(row.key.indexOf('.') + 1)]
225 const value = <Text key={`${key}-value`} color={THEME.ok} backgroundColor={THEME.lifted} bold>{look.nerd ? showNumber(row.value, splitTitle(row.label).unit) : ` ${showNumber(row.value, splitTitle(row.label).unit)} `}</Text>
226 return track(elements, look, key, cardFill(), [
227 entry('down', bare(look.glyph.down), stepNumber(row.value, -1, range), THEME.surface3, THEME.muted, false, look.glyph.down),
228 pillOn(elements, look, `${key}-value-pill`, THEME.lifted, value, THEME.surface3),
229 entry('up', bare(look.glyph.up), stepNumber(row.value, 1, range), THEME.surface3, THEME.muted, false, look.glyph.up),
230 ])
231 }
232 return <Text color={THEME.secondary}>{shown(row)}</Text>
233}
234
235// A setting's first row: the label bold on the left, its control anchored right.
236export const settingHead = ({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, control: unknown) => (
237 <Box key={key} flexDirection="row" columnGap={2} justifyContent="space-between">
238 <Text color={THEME.onSurface} bold>{label}</Text>
239 {control}
240 </Box>
241)
242
243// A hairline across its box, under a setting's head row; a Client, since only a Client learns the width it gets.
244export const hairline = ({ Client }: Pick<Elements, 'Client'>, key: string, bg: string) => (
245 <Client key={key} module="./entry.tsx" width="100%" height={1} props={{ id: key, label: '', isOpen: false, bg, rule: true } satisfies EntryProps} />
246)
247
248// The Settings and Actions sections of a mod's panel or page: one round-bordered card per setting.
249export function settingsBody(elements: Elements, look: Look, { rows, actions, working, run, previews, tips, fields, editors = {}, still = false }: SettingsProps) {
250 const { Box, Text } = elements
251 const settings = [
252 rows.length === 0 ? <Text key="settings-none" color={THEME.muted}>{style.text.noSettings}</Text> : null,
253 ...rows.map((row, index) => {
254 const example = exampleOf(elements, look, previews, row, index)
255 const editor = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
256 const head = editor && !working ? (
257 <Box key={`setting-control-${index}`} flexDirection="row" columnGap={1}>
258 {control(elements, look, row, index, still, tips, fields)}
259 {buttonPill(elements, look, editorScope(index), `setting-editor-${index}`, MARK.open, editor.label, () => run(editor))}
260 </Box>
261 ) : (
262 control(elements, look, row, index, still, tips, fields)
263 )
264 const hint = row.kind !== 'text' ? '' : editor ? (working ? style.text.idleOnly : '') : style.text.editInConfig
265 const note = [row.description, hint].filter(Boolean).join(` ${MARK.sep} `)
266 return (
267 <Box key={`setting-row-${index}`} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1} marginTop={index === 0 ? 0 : 1}>
268 {settingHead(elements, `setting-head-${index}`, splitTitle(row.label).name, head)}
269 {row.description || example ? hairline(elements, `setting-rule-${index}`, cardFill()) : null}
270 {row.description ? (
271 sepText(elements, note, THEME.muted, `setting-text-${index}`)
272 ) : null}
273 {example}
274 </Box>
275 )
276 }),
277 ]
278 const buttons = actions.map((action, index) =>
279 working && !action.isLocal ? (
280 sepText(elements, `${look.glyph.go} ${action.label} ${MARK.sep} ${style.text.idleOnly}`, THEME.muted, `action-${index}`)
281 ) : (
282 buttonPill(elements, look, actionScope(index), `action-${index}`, bare(look.glyph.go), action.label, () => run(action))
283 ),
284 )
285 return (
286 <Box key="settings" flexDirection="column">
287 {/* The cards carry their own border, and the panel's open Settings tab already names them. */}
288 <Box key="settings-cards" flexDirection="column">
289 {cardGap(elements, 'settings')}
290 {settings}
291 </Box>
292 {actions.length > 0 ? section(elements, look, 'actions', 'actions', style.text.actions, buttons) : null}
293 </Box>
294 )
295}
296
297hooks/style.ts 280 lines1// hooks/style.ts, by v-amorim
2// Shared style; every mod carries the same copy. Change values freely, keep the keys. {U+XXXX} is a Nerd Font code point.
3export default {
4 "colors": {
5 "theme": {
6 "bg": "#0D0E17",
7 "surface1": "#141726",
8 "surface2": "#1C2033",
9 "surface3": "#252A42",
10 "lifted": "#2F3654",
11 "trackOff": "#4B5270",
12 "border": "#242839",
13 "onSurface": "#EEEEFA",
14 "muted": "#9BA3C4",
15 "accent": "#7386D0",
16 "secondary": "#B9C6F5",
17 "good": "#49EF95",
18 "ok": "#A2B0EA",
19 "alert": "#CA5F71",
20 "warn": "#FFCB6B",
21 "folder": "#86E1FC",
22 "pill": "#B9C6F5",
23 "addBg": "#1D3B38",
24 "delBg": "#3D2737"
25 },
26 "syntax": {
27 "command": "#7386D0",
28 "option": "#FFAFAF",
29 "text": "#A5CFFF",
30 "variable": "#79C0FF",
31 "number": "#B0C8FF",
32 "operator": "#CDCFE7"
33 },
34 "pills": {
35 "primary": "#252A42",
36 "secondary": "#1C2033",
37 "separator": "#3B446A",
38 "readout": "#7E86A4"
39 },
40 "shimmer": [
41 "#A2B0EA",
42 "#949FCE",
43 "#8993B9"
44 ],
45 "usage": [
46 "#B9C6F5",
47 "#ACB9E6",
48 "#A5A1E9",
49 "#CF95CA",
50 "#CA5FA6",
51 "#CA5F71"
52 ],
53 "statusline": {
54 "pillBorder": "#3A3D4A",
55 "menuBackground": "#1C1D24",
56 "divergence": "#B86CB3",
57 "behind": "#8A9BE0"
58 },
59 "models": {
60 "opus": "#C3A6FF",
61 "sonnet": "#79C0FF",
62 "haiku": "#7CD591",
63 "inherit": "#9BA3C4"
64 },
65 "kinds": {
66 "thinking": { "hue": "#C3A6FF", "wash": "#494267" },
67 "read": { "hue": "#B9C6F5", "wash": "#464C64" },
68 "search": { "hue": "#A5CFFF", "wash": "#404E67" },
69 "edit": { "hue": "#B9C6F5", "wash": "#4C5167" },
70 "shell": { "hue": "#FFCB6B", "wash": "#5B4D3B" },
71 "web": { "hue": "#79C0FF", "wash": "#324A67" },
72 "agent": { "hue": "#F99BD1", "wash": "#593F59" },
73 "tool": { "hue": "#9BA3C4", "wash": "#3D4155" }
74 }
75 },
76 "tags": {
77 "router": {
78 "color": "#F99BD1",
79 "icon": "◆"
80 },
81 "debug": {
82 "color": "#FF9C8B",
83 "icon": "‼"
84 },
85 "interface": {
86 "color": "#EEB154",
87 "icon": "▪"
88 },
89 "writing": {
90 "color": "#C3C55A",
91 "icon": "≡"
92 },
93 "utility": {
94 "color": "#7CD591",
95 "icon": "☼"
96 },
97 "start": {
98 "color": "#2AD7D7",
99 "icon": "»"
100 },
101 "mods": {
102 "color": "#5DCBFF",
103 "icon": "+"
104 },
105 "review": {
106 "color": "#D1A8FF",
107 "icon": "∴"
108 },
109 "personal": {
110 "color": "#9BA3C4",
111 "icon": "●"
112 }
113 },
114 "levels": {
115 "low": { "glyph": "○", "color": "#ACB9E6" },
116 "medium": { "glyph": "◐", "color": "#A5A1E9" },
117 "high": { "glyph": "●", "color": "#CF95CA" },
118 "xhigh": { "glyph": "◉", "color": "#CA5FA6" },
119 "max": { "glyph": "◈", "color": "#CA5F71" }
120 },
121 "statuslineMarks": {
122 "branch": "⑂",
123 "dirty": "●",
124 "diverged": "⇅",
125 "ahead": "↑",
126 "behind": "↓",
127 "pathSep": "›",
128 "menu": "⋯",
129 "close": "×"
130 },
131 "text": {
132 "settings": "Settings",
133 "actions": "Actions",
134 "noSettings": "No settings.",
135 "on": "On",
136 "off": "Off",
137 "locked": "locked",
138 "editInConfig": "edit it in /config",
139 "idleOnly": "available once Claude is idle",
140 "runsCommand": "runs /{command}"
141 },
142 "marks": {
143 "stage": "▸",
144 "cursor": "❯",
145 "bullet": "•",
146 "ok": "✓",
147 "warn": "!",
148 "sep": "·",
149 "stop": "○",
150 "stopOpen": "●",
151 "rail": "│",
152 "rule": "─",
153 "track": "━━",
154 "knob": "●",
155 "spinner": "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏",
156 "open": "↗",
157 "strip": "▎"
158 },
159 "caps": {
160 "left": "{U+E0B6}",
161 "right": "{U+E0B4}"
162 },
163 "glyphs": {
164 "safe": {
165 "on": "[◉]",
166 "off": "[○]",
167 "go": "[↵]",
168 "reveal": "⊞",
169 "cycle": "[↻]",
170 "down": "[−]",
171 "up": "[+]",
172 "locked": "[■]",
173 "close": "[×]",
174 "warn": "!",
175 "ok": "✓",
176 "expanded": "▾",
177 "collapsed": "▸",
178 "more": "▾",
179 "less": "▴",
180 "back": "<",
181 "title": "▸",
182 "gear": "⊛",
183 "copy": "⧉",
184 "icon": { "cost": "$", "arrow": "→" },
185 "mark": {
186 "thinking": "∴",
187 "read": "≡",
188 "search": "?",
189 "edit": "±",
190 "shell": "$",
191 "web": "@",
192 "agent": "◆",
193 "tool": "•"
194 }
195 },
196 "nerd": {
197 "on": " {U+F205} ",
198 "off": " {U+F204} ",
199 "go": " {U+F054} ",
200 "reveal": "{U+EAF7}",
201 "cycle": " {U+F021} ",
202 "down": " {U+F068} ",
203 "up": " {U+F067} ",
204 "locked": " {U+F023} ",
205 "close": " {U+F00D} ",
206 "warn": "{U+EA6C}",
207 "ok": "{U+EAB2}",
208 "expanded": "{U+EAB4}",
209 "collapsed": "{U+EAB6}",
210 "more": "{U+F0D7}",
211 "less": "{U+F0D8}",
212 "back": "{U+F053}",
213 "title": "{U+F054}",
214 "gear": "{U+F0493}",
215 "copy": "{U+F018F}",
216 "icon": {
217 "settings": "{U+F1DE}",
218 "actions": "{U+F0E7}",
219 "wrapup": "{U+F187}",
220 "features": "{U+F005}",
221 "mod": "{U+F12E}",
222 "installed": "{U+F487}",
223 "arrow": "{U+F061}",
224 "not-installed": "{U+F019}",
225 "icons": "{U+F031}",
226 "updates": "{U+F0AA}",
227 "welcome": "{U+F135}",
228 "apply": "{U+F00C}",
229 "cost": "{U+F155}",
230 "skill": "{U+F0D0}",
231 "prompt": "{U+F075}",
232 "fold": "{U+F066}",
233 "files": "{U+EB86}",
234 "replay": "{U+EA82}",
235 "folder": "{U+EA83}",
236 "folder-open": "{U+EAF7}",
237 "file": "{U+EA7B}",
238 "ext-ts": "{U+E628}",
239 "ext-react": "{U+E625}",
240 "ext-js": "{U+E781}",
241 "ext-json": "{U+E60B}",
242 "ext-md": "{U+E609}",
243 "ext-py": "{U+E73C}",
244 "ext-config": "{U+E615}",
245 "ext-sh": "{U+F489}",
246 "Edit": "{U+F040}",
247 "MultiEdit": "{U+F040}",
248 "NotebookEdit": "{U+F040}",
249 "Write": "{U+F15B}",
250 "Read": "{U+F02D}",
251 "Bash": "{U+F120}",
252 "Grep": "{U+F002}",
253 "Glob": "{U+F002}",
254 "Agent": "{U+EBBC}",
255 "Task": "{U+EBBC}",
256 "Skill": "{U+F0D0}",
257 "WebFetch": "{U+F0AC}",
258 "WebSearch": "{U+F0AC}",
259 "TodoWrite": "{U+F0AE}",
260 "tool": "{U+F0AD}",
261 "menu-skills": "{U+F0D0}",
262 "menu-prompts": "{U+F075}",
263 "menu-filetree": "{U+EB86}",
264 "menu-progress": "{U+EBBC}",
265 "menu-headsup": "{U+EA6C}"
266 },
267 "mark": {
268 "thinking": "{U+F09D1}",
269 "read": "{U+F02D}",
270 "search": "{U+F002}",
271 "edit": "{U+F040}",
272 "shell": "{U+F120}",
273 "web": "{U+F0AC}",
274 "agent": "{U+EBBC}",
275 "tool": "{U+F0AD}"
276 }
277 }
278 }
279}
280hooks/speed.ts 34 lines1// mod-model-effort / hooks/speed.ts, by v-amorim: reply speed from a stream's times, pure.
2export type Speed = { first: number; rate: number }
3
4// Times in ms. No first chunk, no tokens or no streaming time gives no reading.
5export function replySpeed(start: number, first: number | undefined, end: number, tokens: number | undefined): Speed | null {
6 if (first === undefined || !tokens || tokens <= 0) return null
7 const streamed = end - first
8 const wait = first - start
9 if (!(streamed > 0) || !(wait >= 0)) return null
10 const rate = Math.round((tokens * 1000) / streamed)
11 return Number.isFinite(rate) && rate > 0 ? { first: wait / 1000, rate } : null
12}
13
14export const isSpeed = (value: unknown): value is Speed => {
15 const speed = value as Speed | null | undefined
16 return Boolean(speed) && Number.isFinite(speed!.first) && Number.isFinite(speed!.rate)
17}
18
19// One decimal under 10 seconds, whole seconds from there.
20export function firstText(seconds: number) {
21 const tenths = Math.round(seconds * 10) / 10
22 return tenths < 10 ? `${tenths.toFixed(1)}s` : `${Math.round(seconds)}s`
23}
24
25// Passes every chunk on in order, and hands each one to `seen` as it goes by.
26export async function* watch<C, R>(stream: AsyncGenerator<C, R>, seen: (chunk: C) => Promise<void>): AsyncGenerator<C, R> {
27 let step = await stream.next()
28 while (!step.done) {
29 await seen(step.value)
30 step = await stream.next(yield step.value)
31 }
32 return step.value
33}
34hooks/theme.ts 46 lines1// hooks/theme.ts, by v-amorim
2// Shared theme; every mod carries the same copy. Colors, marks and glyphs live in style.ts.
3import style from './style'
4
5export const THEME = style.colors.theme
6
7// Shell command roles: what runs, its options, quoted text, variables, numbers and the glue between steps.
8export const SYNTAX = style.colors.syntax
9
10// One cell each and never an emoji: ⚠ switches to its emoji form in some fonts and draws two cells wide.
11export const MARK = style.marks
12
13// style.ts writes Nerd glyphs as {U+XXXX}, since private-use characters do not survive many editors and tools.
14const decode = (text: string) => text.replace(/\{U\+([0-9A-F]+)\}/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
15
16export function decodeAll<T>(value: T): T {
17 if (typeof value === 'string') return decode(value) as T
18 if (Array.isArray(value)) return value.map(decodeAll) as T
19 return Object.fromEntries(Object.entries(value as object).map(([key, inner]) => [key, decodeAll(inner)])) as T
20}
21
22// A pill's rounded ends, drawn only where the font has them.
23export const CAPS = decodeAll(style.caps)
24
25export const statusColor = (note: string) =>
26 note.startsWith(MARK.ok) ? THEME.good : note.startsWith(MARK.warn) ? THEME.alert : THEME.muted
27
28const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
29const isHex = (color: string) => /^#[0-9a-f]{6}$/i.test(color)
30
31// The color `t` of the way from `from` to `to`; a color that is not #RRGGBB switches halfway.
32export function mix(from: string, to: string, t: number) {
33 if (t <= 0) return from
34 if (t >= 1) return to
35 if (!isHex(from) || !isHex(to)) return t < 0.5 ? from : to
36 const end = channels(to)
37 return `#${channels(from).map((value, at) => Math.round(value + (end[at]! - value) * t).toString(16).padStart(2, '0')).join('')}`
38}
39
40// The share of a hue left in a subpill or count badge, washed into the fill it sits on.
41export const WASH = 0.72
42
43// A text.ts string with its {name} slots filled; a slot with no value stays as written, so a missing one shows.
44export const fill = (template: string, values: Record<string, string | number> = {}) =>
45 template.replace(/\{(\w+)\}/g, (slot, name: string) => (name in values ? String(values[name]) : slot))
46hooks/pills.tsx 167 lines1// hooks/pills.tsx, by v-amorim
2// Shared pill row; every mod that draws in the row carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, mix, type EntryProps, type Tip } from './entry'
6import style from './style'
7import { CAPS, decodeAll, THEME, WASH } from './theme'
8
9// The menu group on Moonlight surface-3, readouts on surface-2.
10export const PILL = { primary: style.colors.pills.primary, secondary: style.colors.pills.secondary }
11
12// Menu entries left to right, by the id in their `menu-pad-<id>` Box key; an unknown id goes last.
13const ENTRY_ORDER = ['manager', 'skills', 'prompts', 'filetree', 'progress', 'headsup']
14
15type Node = { props?: Record<string, unknown>; children?: unknown[] } | null | undefined
16type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
17
18const keyOf = (node: unknown) => (node as Node)?.props?.key as string | undefined
19const childrenOf = (node: unknown) => (node as Node)?.children ?? []
20
21// Splits next(e) into the menu entries other mods drew, the rest of the row, what else the band holds, and the hover tip under the row.
22export function splitRow(others: unknown) {
23 if (keyOf(others) !== 'menu-rows') return { group: [] as unknown[], row: [] as unknown[], rest: [others], tip: null as unknown }
24 const children = childrenOf(others)
25 const rest = children.filter(child => keyOf(child) !== 'menu-row' && keyOf(child) !== 'menu-tip')
26 const pills = childrenOf(children.find(child => keyOf(child) === 'menu-row'))
27 const groupPill = pills.find(child => keyOf(child) === 'menu-group')
28 const body = childrenOf(groupPill).find(child => keyOf(child) === 'menu-group-body')
29 return {
30 // A mod still running an older copy may draw `menu-group-sep-*` separators between entries; the group drops them.
31 group: childrenOf(body).filter(child => !keyOf(child)?.startsWith('menu-group-sep')),
32 row: pills.filter(child => keyOf(child) !== 'menu-group'),
33 rest,
34 tip: children.find(child => keyOf(child) === 'menu-tip') ?? null,
35 }
36}
37
38// The one-line tip above the pill row, filled while the pointer is on an entry: its "Name:" in bold accent, the rest muted.
39export const tipLine = ({ Box, Text }: Elements, text: string) => {
40 const at = text.indexOf(': ')
41 return (
42 <Box key="menu-tip" paddingLeft={2} flexDirection="row">
43 {at < 0 ? null : <Text color={THEME.accent} bold>{text.slice(0, at + 1)}</Text>}
44 <Text color={THEME.muted}>
45 {(at < 0 ? text : text.slice(at + 1)).split(style.marks.sep).flatMap((part, index) => (index === 0 ? [part] : [<Text key={`sep-${index}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
46 </Text>
47 </Box>
48 )
49}
50
51// One pill with its own caps where the font has them; `on` is what it sits on, `hover` lights the caps with a Button scoped `key`.
52export function pill({ Box, Text }: Elements, nerd: boolean, bg: string, body: unknown, key = 'menu-pill', on?: string, hover?: string) {
53 const under = on ? { backgroundColor: on } : {}
54 const lit = hover ? { hover: { scope: key, color: hover } } : {}
55 return (
56 <Box key={key} flexDirection="row">
57 {nerd ? <Text key={`${key}-cap-left`} color={bg} {...under} {...lit}>{CAPS.left}</Text> : null}
58 <Box key={`${key}-body`} flexDirection="row" backgroundColor={bg}>
59 {body}
60 </Box>
61 {nerd ? <Text key={`${key}-cap-right`} color={bg} {...under} {...lit}>{CAPS.right}</Text> : null}
62 </Box>
63 )
64}
65
66// mod-menu's `labels`: `always` icon and label, `never` the icon alone, `auto` the icon alone while the row overflows (Nerd only; safe keeps labels).
67export type LabelsMode = 'auto' | 'always' | 'never'
68
69// The value of `{ plugin: 'mod-menu', key: 'labels' }` as a mode; a missing or unknown value is `auto`, so a mod works without mod-menu.
70export const labelsOf = (value: unknown): LabelsMode => (value === 'always' || value === 'never' ? value : 'auto')
71
72type ClientNode = { type: 'Client'; props: { props: Record<string, unknown> } }
73const isClient = (node: unknown): node is ClientNode => (node as { type?: string } | null)?.type === 'Client'
74
75// The cells a drawn row takes with every label shown: a Client that may go compact says its own `cells`, or is an entry.tsx one.
76const cellsOf = (node: unknown): number => {
77 if (Array.isArray(node)) return node.reduce((sum: number, each) => sum + cellsOf(each), 0)
78 if (typeof node === 'string') return [...node].length
79 if (!node || typeof node !== 'object') return 0
80 if (isClient(node)) {
81 const { props } = node.props
82 return typeof props.cells === 'number' ? props.cells : entryCells({ ...(props as EntryProps), compact: false })
83 }
84 // A `menu-flash` word comes and goes with the pointer; counting it would flip the row under the pointer.
85 const kids = childrenOf(node).filter(each => each && !keyOf(each)?.startsWith('menu-flash'))
86 return kids.reduce((sum: number, each) => sum + cellsOf(each), Number((node as Node)?.props?.columnGap ?? 0) * Math.max(0, kids.length - 1))
87}
88
89const compacted = (node: unknown): unknown => {
90 if (Array.isArray(node)) return node.map(compacted)
91 if (!node || typeof node !== 'object') return node
92 if (isClient(node)) return node.props.props.compact === false ? { ...node, props: { ...node.props, props: { ...node.props.props, compact: true } } } : node
93 const kids = (node as Node)?.children
94 return kids ? { ...node, children: kids.map(compacted) } : node
95}
96
97// A panel's entry in the menu group, keyed `menu-pad-<id>` for the row's order; the open one is a pill of its own.
98export function menuEntry({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, id: string, { labels, ...props }: Omit<EntryProps, 'id' | 'bg' | 'tight'> & { labels?: LabelsMode }) {
99 // The open panel reads as selected, like an On switch: ok text on ok washed into the group fill.
100 const bg = props.isOpen ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary
101 const glyph = (style.glyphs.nerd.icon as Record<string, string>)[`menu-${id}`]
102 const icon = nerd && !props.lead && glyph ? decodeAll(glyph) : undefined
103 // The icon sits tight against the cap, so the label that follows brings its own gap.
104 const shown = icon ? { lead: icon, label: labels === 'never' ? '' : ` ${props.label}`, ...(labels === 'auto' ? { compact: false } : {}) } : {}
105 // On Nerd the caps take the label's padding cells, so an entry is as wide as before the caps.
106 const entry = <Client key={`menu-entry-${id}`} module="./entry.tsx" height={1} props={{ id, bg, ...props, ...(props.isOpen ? { color: THEME.ok } : {}), ...shown, nerd, tight: nerd } satisfies EntryProps} />
107 return (
108 <Box key={`menu-pad-${id}`} flexDirection="row">
109 {/* A closed entry gets caps in the group's own color, so opening a panel never shifts its neighbours. */}
110 {pill({ Box, Text }, nerd, bg, entry, props.isOpen ? `menu-open-${id}` : `menu-closed-${id}`, PILL.primary)}
111 </Box>
112 )
113}
114
115// The band above the prompt: what else it holds (Claude Code's own effort line), then the pill row, so the pills sit on the input.
116export function pillRow(elements: Elements & { Client?: (props: any) => RenderElement }, nerd: boolean, group: unknown[], row: unknown[], rest: unknown[], tip: unknown = null, columns?: number) {
117 const { Box, Client, Text } = elements
118 // Load order changes with an install or a toggle, so entries sort by a fixed rank.
119 const rank = (entry: unknown) => {
120 const at = ENTRY_ORDER.indexOf(String(keyOf(entry) ?? '').replace(/^menu-pad-/, ''))
121 return at < 0 ? ENTRY_ORDER.length : at
122 }
123 // Like a choice bar: the entries side by side on one pill, the open panel's lifted into its own, a divider Client between neighbours.
124 const sorted = [...group].sort((a, b) => rank(a) - rank(b))
125 const entries = Client
126 ? sorted.flatMap((entry, at) => (at === 0 ? [entry] : [<Client key={`menu-group-sep-${at}`} module="./entry.tsx" height={1} props={{ id: `sep-${at}`, label: '', isOpen: false, bg: PILL.primary, rail: true } satisfies EntryProps} />, entry]))
127 : sorted
128 const pills = (
129 <Box key="menu-row" flexDirection="row" columnGap={1}>
130 {group.length > 0 ? pill(elements, nerd, PILL.primary, entries, 'menu-group') : null}
131 {row}
132 </Box>
133 )
134 // Measured with every label shown, so a row cut to icons never grows back into a row that does not fit.
135 const fits = columns === undefined || cellsOf(pills) <= columns
136 return (
137 <Box key="menu-rows" flexDirection="column">
138 {rest}
139 {/* Above the row, kept even when empty so nothing above jumps when a tip appears. */}
140 {tip ?? tipLine(elements, ' ')}
141 {fits ? pills : compacted(pills)}
142 </Box>
143 )
144}
145
146// One chip per view of a panel (its list, its settings), standing in for the title: id, label, icon glyph, an optional count and an optional hover tip.
147export type Tab = { id: string; label: string; icon: string; isOpen: boolean; count?: number; tip?: Tip }
148
149// A panel's title line: the open view on surface3 and bold, the others on surface2 and muted; a click swaps the view.
150export function tabRow({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, tabs: Tab[], still: boolean) {
151 return (
152 <Box key="tab-row" flexDirection="row" columnGap={1}>
153 {tabs.map(tab => {
154 const bg = tab.isOpen ? THEME.surface3 : THEME.surface2
155 // Client props refuse an undefined value, so a tab without a count or tip leaves the key out.
156 const countColor = tab.isOpen ? THEME.accent : style.colors.pills.readout
157 const badge = tab.count === undefined ? {} : { badge: { text: String(tab.count), color: countColor, bg: mix(countColor, bg, WASH) } }
158 const tip = tab.tip ? { tip: tab.tip } : {}
159 const entry = (
160 <Client key={tab.id} module="./entry.tsx" height={1} props={{ id: tab.id, label: tab.label, isOpen: tab.isOpen, bg, idle: THEME.muted, still, subLead: tab.icon, subLeadColor: THEME.accent, nerd, ...(tab.isOpen ? { color: THEME.secondary } : {}), ...badge, ...tip } satisfies EntryProps} />
161 )
162 return pill({ Box, Text }, nerd, bg, entry, `${tab.id}-pill`)
163 })}
164 </Box>
165 )
166}
167hooks/text.ts 39 lines1// mod-model-effort / hooks/text.ts, by v-amorim
2// Every string this mod shows. Edit the values to reword or translate; keep the keys and the {slots}.
3export default {
4 "sendFirst": "mod-model-effort: send a message first, so the meter knows the model",
5 "settingsNotJson": "{mark} mod-model-effort: settings.json left untouched, it is not valid JSON",
6 "switchFailed": "{mark} mod-model-effort: could not switch to {model}: {error}",
7 "pickerFailed": "{mark} mod-model-effort: could not open /model: {error}",
8 "modelBusy": "Claude is working, {name} is available once it is idle",
9 "allModels": "All models…",
10 "labelModel": "Model",
11 "labelEffort": "Effort",
12 "labelClose": "Close",
13 "tipModel": "switches the model",
14 "tipRest": "shows the effort levels",
15 "tipLevel": "sets effort to {level}",
16 "tipModelEntry": "switches to this model",
17 "tipModelClose": "closes the model menu",
18 "tipCardClose": "closes the effort card",
19 "tipAll": "opens the full model picker",
20 "tipTab": "shows this mod's settings",
21 "tipSpeed": "last reply: first token {first}, {rate} tok/s",
22 "paneName": "Effort",
23 "paneSettings": "Settings",
24 "commandDescription": "Open the effort settings panel with `settings`",
25 "usage": "mod-model-effort: use /mod-model-effort settings",
26 "settingsWaiting": "Effort settings waiting: {reason}",
27 "statusHint": "Click a setting to change it",
28 "statusHintSaved": "The mod reloads",
29 "settingSaved": "{label} saved",
30 "levelNames": {
31 "low": "low",
32 "medium": "medium",
33 "high": "high",
34 "xhigh": "xhigh",
35 "max": "max"
36 },
37 "tipToast": {"label":"Dismiss","text":"hides this message"}
38}
39