SLOPSHOPPER

mod-model-effort

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

newpanebandcommandtoastprocess
★ 2v1.2.4MITupdated 2026-10-26v-amorim/moonlight-claude-mods/mod-model-effort
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-model-effort
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mod-model-effort ⎿ mod-model-effort: mod-model-effort: use /mod-model-effort settings ⟨Claude Code's own drawing⟩ ▣ client module ./meter.tsx ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▣ client module ./meter.tsx
README

<!-- mod-model-effort / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->

mod-model-effort

Part of [moonlight-claude-mods][doc-root].

Shows the model and effort level; a click changes either.

Install

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

How to open

The model pill.

See it

The model panel
![][img-model-effort-panel]
Effort on hover, models on click
https://github.com/user-attachments/assets/bc90d5e3-6bf5-4981-8bdd-410abb7465c8

Features

  • Model and effort: A pill shows the model and the effort level of the session. A click changes either one.
  • Reply speed: Hover the pill to see the last reply's speed: how long the first token took and the tokens per second after it. Only the main session's replies count.
  • Command hooks: On SessionStart, PostModelSwitch and ConfigChange, a small shell hook appends the event's JSON as one line to ~/.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.

Settings

Change them with /mod-model-effort settings or the gear pill.

SettingDefaultWhat it does
Iconsautoauto 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
AnimationsonEntries shimmer while they wait. Hover colors also fade. Off, everything draws still

License

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

Source 14 files
hooks/register.tsx 635 lines
1// 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}
635
hooks/bridge.ts 107 lines
1// 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}
107
hooks/levels.ts 42 lines
1// 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}
42
hooks/entry.tsx 241 lines
1// 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
241
hooks/look.tsx 279 lines
1// 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
279
hooks/meter.tsx 120 lines
1// 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
120
hooks/settings.tsx 297 lines
1// 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
297
hooks/style.ts 280 lines
1// 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}
280
hooks/speed.ts 34 lines
1// 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}
34
hooks/theme.ts 46 lines
1// 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))
46
hooks/pills.tsx 167 lines
1// 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}
167
hooks/text.ts 39 lines
1// 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