After each finished turn, up to three short next-prompt suggestions sit above the input as one-line rows; a click fills the prompt without sending

<!-- mod-next-steps / README.md, by v-amorim --> <!-- Generated from the root README, plugin.json and showcase.json; edit those. -->
Part of [moonlight-claude-mods][doc-root].
Suggests up to three next prompts after each turn.
Inside Claude Code:
/plugin marketplace add v-amorim/moonlight-claude-mods
/plugin install mod-next-steps@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].
Automatic.
| Three suggestions |
|---|
| ![][img-next-steps] |
| Picking one by its digit |
|---|
| https://github.com/user-attachments/assets/d89e12c2-c107-421e-ba16-653f2ae55deb |
By default this mod makes no model call. Turn on Suggest next steps (/mod-next-steps settings) and it calls a model, which spends tokens and counts against your plan quota.
After each finished turn of the main agent (not an interrupted one, not a subagent's), it forks your session to ask for the suggestions. The fork runs on your session's model and reuses the cached conversation, so a long session still makes a sizable request every turn.
With Suggest next steps off, the default, no request is made.
Change them with /mod-next-steps settings or the gear pill.
| Setting | Default | What it does |
|---|---|---|
| Animations | on | Entries shimmer while they wait. Hover colors also fade. Off, everything draws still |
| Suggest next steps | off | Lists next prompts after a turn. The model writes them after each finished turn. Off, no request is made |
| Suggestions | 3 | How many suggestions to show, 1 to 3 |
| Icons | auto | auto picks Nerd Font or safe glyphs. It uses Nerd Font icons when the terminal's font is a Nerd Font (read from Windows Terminal, iTerm2, VS Code, Ghostty, kitty, WezTerm, Alacritty, GNOME Terminal or Konsole) and safe Unicode glyphs elsewhere. nerd and safe force one |
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-next-steps]: https://github.com/user-attachments/assets/82b8aa72-46c1-4e78-9c39-94423d61d977
hooks/register.tsx 432 lines1// mod-next-steps / hooks/register.tsx, by v-amorim
2// Registers the mod: wires the suggestion band, settings pane, commands and toasts.
3import type { ConfigValue, EngineInterface, Register, RenderChildren } from 'claude-code'
4
5import { cardFill, detectNerd, FOOTER_ROWS, lookFor, paneFooter, sepText, titleEdge, type IconMode, withMark } from './look'
6import { settingChoice, settingsBody, type Action, type Previews, type Range } from './settings'
7import { fill, MARK, statusColor, THEME } from './theme'
8import { isTipMessage, tipText, type EntryMessage, type EntryProps, type Tip, type TipMessage } from './entry'
9import { pill, pillRow, splitRow, tabRow, tipLine } from './pills'
10import { TOAST_MS, ToastList, toastCards } from './toasts'
11import { recordSpend } from './spend'
12import text from './text'
13
14const PANE = 'nextsteps'
15const MAX_CHARS = 100
16// Cells a suggestion row spends besides its text: the card's border and padding, the pill's caps, digit and padding, and the close pill beside the first row.
17const ROW_CHROME = 19
18const MIN_ROOM = 10
19
20const keyOf = (node: unknown) => String((node as { props?: { key?: unknown } } | null)?.props?.key ?? '')
21
22const fit = (line: string, width: number) => (line.length <= width ? line : `${line.slice(0, width - 1)}…`)
23
24const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
25
26// The person's settings (userConfig), fixed per activation: a change reloads the mod.
27let iconMode: IconMode = 'auto'
28let enabled = false
29let count = 3
30let look = lookFor(false)
31// The control under the pointer, from its tip post: the band's tip row and the pane footer show it.
32let hovered: TipMessage | null = null
33const hoveredTip = (): Tip | null => (hovered?.id && hovered.label ? { glyph: hovered.glyph ?? '', label: hovered.label, text: hovered.text ?? '' } : null)
34
35// A band redraw can replace a hovered entry, whose leave and quiet timer then never come, so a band tip ends on its own.
36const BAND_TIP_MS = 5000
37let bandTipAt = 0
38// The tip the band stopped showing; the pane footer keeps its own.
39let bandExpired: TipMessage | null = null
40
41function expireBandTip($: EngineInterface) {
42 const at = ++bandTipAt
43 $.clock.after(BAND_TIP_MS, () => {
44 if (at !== bandTipAt || !hovered?.id) return
45 bandExpired = hovered
46 $.ui.invalidate('ui.render')
47 })
48}
49
50// Suggestions on show. `epoch` counts turn starts and submits, so a reply that arrives late is dropped.
51let steps: string[] = []
52let epoch = 0
53let working = false
54let note = ''
55let actions: Action[] = []
56let previews: Previews | undefined
57let fields: Record<string, Range> | undefined
58
59async function detectLook($: EngineInterface) {
60 if (iconMode !== 'auto') return lookFor(iconMode === 'nerd')
61 const env = {
62 WT_SESSION: await $.env.get('WT_SESSION'),
63 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
64 ITERM_PROFILE: await $.env.get('ITERM_PROFILE'),
65 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
66 ALACRITTY_WINDOW_ID: await $.env.get('ALACRITTY_WINDOW_ID'),
67 GNOME_TERMINAL_SCREEN: await $.env.get('GNOME_TERMINAL_SCREEN'),
68 KONSOLE_VERSION: await $.env.get('KONSOLE_VERSION'),
69 }
70 const host = {
71 localAppData: ((await $.env.get('LOCALAPPDATA')) ?? '').replace(/\\/g, '/'),
72 home: ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/'),
73 read: (path: string) => $.fs.read(path).then(String),
74 run: async (argv: string[]) => {
75 const result = await $.process.run(argv, { timeoutMs: 5000 })
76 if (result.exitCode !== 0) throw new Error(result.stderr)
77 return result.stdout
78 },
79 }
80 return lookFor(await detectNerd(env, host))
81}
82
83// Reading the terminal's settings takes a while, so on auto the last answer, kept in this mod's store, draws until it returns.
84async function savedLook($: EngineInterface) {
85 if (iconMode !== 'auto') return look
86 try {
87 const nerd = await $.store.get('look-nerd')
88 return typeof nerd === 'boolean' ? lookFor(nerd) : look
89 } catch {
90 return look
91 }
92}
93
94async function saveLook($: EngineInterface) {
95 try {
96 await $.store.set('look-nerd', look.nerd)
97 } catch {
98 // Without a store the mod still draws; it only starts from safe again next time.
99 }
100}
101
102// Hooks of one event run in turn, so the slow font check runs beside them instead of holding up every mod after this one.
103let lookLoad: Promise<void> | null = null
104
105function loadLook($: EngineInterface) {
106 lookLoad ??= (async () => {
107 look = await savedLook($)
108 void detectLook($).then(async found => {
109 const isChanged = found.nerd !== look.nerd
110 look = found
111 await saveLook($)
112 if (isChanged) $.ui.invalidate('ui.render')
113 }).catch(() => {
114 // A failed check keeps the icons drawn now.
115 })
116 })()
117 return lookLoad
118}
119
120const question = (limit: number) =>
121 `Suggest up to ${limit} short prompts I might send you next, given this conversation. ` +
122 `Each is one line under 80 characters, written the way I would type it. ` +
123 `Reply with the prompts only, one per line, no numbering, no quotes, no commentary.`
124
125function parseSteps(text: string, limit: number) {
126 const lines = text
127 .split(/\r?\n/)
128 .map(line => line.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').replace(/^["'`]+|["'`]+$/g, '').trim())
129 .map(line => line.charAt(0).toUpperCase() + line.slice(1))
130 .filter(line => line.length > 0 && line.length <= MAX_CHARS)
131 return [...new Set(lines)].slice(0, limit)
132}
133
134let isSuggesting = false
135const REASON_CHARS = 80
136// The session that already got the "side check unavailable" toast.
137let warnedSession: string | null = null
138
139// One toast per session when the side model call fails, so a blocked plan is not silent forever.
140async function sideUnavailable($: EngineInterface, reason: string) {
141 const session = await $.session.id().catch(() => '')
142 if (warnedSession === session) return
143 warnedSession = session
144 const short = reason.replace(/_/g, ' ').replace(/\s+/g, ' ').trim().slice(0, REASON_CHARS)
145 const message = short ? fill(text.sideUnavailableWhy, { reason: short }) : text.sideUnavailable
146 $.ui.toast(message)
147}
148
149async function suggest($: EngineInterface, mine: number) {
150 isSuggesting = true
151 $.ui.invalidate('ui.render')
152 try {
153 const reply = await $.model.fork({ prompt: question(count) })
154 recordSpend($, await $.session.model().catch(() => ''), !reply.isAnswered && reply.reason === 'nothing-to-fork' ? null : reply)
155 if (!reply.isAnswered && reply.reason === 'api-error') await sideUnavailable($, [reply.error, reply.status ? String(reply.status) : ''].filter(Boolean).join(' '))
156 if (mine !== epoch || !reply.isAnswered) return
157 const found = parseSteps(reply.text, count)
158 if (found.length === 0) return
159 steps = found
160 $.ui.invalidate('ui.render')
161 } catch (error) {
162 await sideUnavailable($, error instanceof Error ? error.message : String(error))
163 } finally {
164 if (mine === epoch) {
165 isSuggesting = false
166 $.ui.invalidate('ui.render')
167 }
168 }
169}
170
171function clearSteps($: EngineInterface) {
172 epoch += 1
173 hovered = null
174 if (isSuggesting) {
175 isSuggesting = false
176 $.ui.invalidate('ui.render')
177 }
178 if (steps.length === 0) return
179 steps = []
180 $.ui.invalidate('ui.render')
181}
182
183async function fillFrom($: EngineInterface, index: number) {
184 const text = steps[index]
185 if (!text) return
186 const draft = await $.prompt.read()
187 // The digit that pressed it may still sit in the prompt; the suggestion takes its place.
188 if (draft.text.trim() === '' || draft.text.trim() === String(index + 1)) await $.prompt.fill({ text, mode: 'replace' })
189 else await $.prompt.fill({ text: ` ${text}`, mode: 'append' })
190}
191
192const isPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
193
194// `/mod-next-steps settings` toggles the panel; an unplaced pane is waiting, not open.
195async function toggleSettings($: EngineInterface) {
196 if (await isPaneOpen($)) {
197 await $.ui.close({ id: PANE })
198 await $.state.set({ plugin: 'mod-next-steps', key: 'pane' }, false)
199 return
200 }
201 note = ''
202 const showcase = await $.fs.read(`${$.plugin.root}/showcase.json`).then(text => JSON.parse(String(text))).catch(() => null)
203 previews = showcase?.previews
204 fields = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).then(text => JSON.parse(String(text)).userConfig).catch(() => undefined)
205 actions = (Array.isArray(showcase?.actions) ? showcase.actions : []).map((action: Action) => ({ ...action, isLocal: action.command === 'mod-next-steps' }))
206 const opened = await $.ui.open({ id: PANE, title: 'Next steps', closeOnEscape: true })
207 if (!opened.isPlaced) $.ui.toast(fill(text.settingsWaiting, { reason: opened.reason }))
208 await $.state.set({ plugin: 'mod-next-steps', key: 'pane' }, opened.isPlaced)
209}
210
211// What `/mod-next-steps <args>` does; the panel's own actions call it directly, since $.command.run skips the calling plugin.
212async function answer($: EngineInterface, args: string) {
213 if (args.trim() === 'clear') {
214 clearSteps($)
215 return text.suggestionsCleared
216 }
217 await toggleSettings($)
218 return ''
219}
220
221const toasts = new ToastList('nextsteps')
222let still = false
223
224async function saveSetting($: EngineInterface, key: string, value: ConfigValue) {
225 const row = (await $.config.list()).find(each => each.key === key)
226 if (!row) return
227 const clamped = row.key === 'mod-next-steps.count' && typeof value === 'number' ? Math.min(3, Math.max(1, value)) : value
228 const result = await $.config.set({ key: row.key, value: clamped })
229 note = 'deny' in result && result.deny ? `${MARK.warn} ${result.deny}` : `${MARK.ok} ${fill(text.settingSaved, { label: row.label })}`
230 $.ui.invalidate('ui.render')
231}
232
233export const register: Register = (on, options) => {
234 still = options.animations === false
235 toasts.isStill = still
236 // This mod's toasts draw as cards in the band; Claude Code's own box is narrow and cuts them.
237 on('ui.toast', async ($, e) => {
238 const id = toasts.add(e.text)
239 $.ui.invalidate('ui.render')
240 $.clock.after(e.timeoutMs ?? TOAST_MS, () => {
241 toasts.drop(id)
242 $.ui.invalidate('ui.render')
243 })
244 return { value: undefined }
245 })
246
247 iconMode = (options.icons as IconMode | undefined) ?? 'auto'
248 enabled = options.enabled === true
249 count = Math.min(3, Math.max(1, Math.round(Number(options.count ?? 3)) || 3))
250 look = lookFor(iconMode === 'nerd')
251
252 on('session.start', async ($, e, next) => {
253 await loadLook($)
254 await $.command.register({
255 name: 'mod-next-steps',
256 description: text.commandDescription,
257 argumentHint: '[settings|clear]',
258 })
259 await $.state.set({ plugin: 'mod-next-steps', key: 'pane' }, await isPaneOpen($))
260 $.ui.invalidate('ui.render')
261
262 return next(e)
263 })
264
265 on('command.run', { command: 'mod-next-steps' }, async ($, e) => ({ text: await answer($, e.args ?? '') }))
266
267 // Subagent turns carry agentId and say nothing about the main prompt.
268 on('turn.start', async ($, e, next) => {
269 if (!(e as { agentId?: string }).agentId) {
270 working = true
271 clearSteps($)
272 }
273
274 return next(e)
275 })
276
277 on('turn.complete', async ($, e, next) => {
278 if (e.agentId) return next(e)
279 working = false
280 const result = await next(e)
281 if (enabled && e.reason === 'answer' && !e.isAborted && e.answer.trim() !== '') {
282 const mine = epoch
283 // Off the hook's own time: the turn never waits for the model.
284 $.clock.after(0, () => suggest($, mine))
285 }
286
287 return result
288 })
289
290 on('prompt.submit', async ($, e, next) => {
291 clearSteps($)
292
293 return next(e)
294 })
295
296 // A /resume or /clear ends this session: its suggestions, and a reply still on its way, belong to it alone.
297 on('session.end', async ($, e, next) => {
298 working = false
299 clearSteps($)
300
301 return next(e)
302 })
303
304 on('ui.message', async ($, e, next) => {
305 if (isTipMessage(e.data)) {
306 hovered = e.data
307 if (hovered.id) expireBandTip($)
308 $.ui.invalidate('ui.render')
309 return next(e)
310 }
311 const change = settingChoice(e.data)
312 if (change) await saveSetting($, change.key, change.value)
313 const clicked = e.data as EntryMessage
314 const dismissed = clicked?.type === 'menu-entry' ? toasts.idOf(clicked.id) : undefined
315 if (dismissed !== undefined) {
316 toasts.drop(dismissed)
317 $.ui.invalidate('ui.render')
318 return next(e)
319 }
320 if (clicked?.type === 'menu-entry' && clicked.id.startsWith('steps-row-')) await fillFrom($, Number(clicked.id.slice('steps-row-'.length)))
321 if (clicked?.type === 'menu-entry' && clicked.id === 'steps-dismiss') clearSteps($)
322
323 return next(e)
324 })
325
326 on('ui.close', async ($, e, next) => {
327 if (e.id === PANE) await $.state.set({ plugin: 'mod-next-steps', key: 'pane' }, false)
328
329 return next(e)
330 })
331
332 // One panel at a time: another mod's pane opening closes this one, deferred past that plugin's dispatch.
333 on('state.set', async ($, e, next) => {
334 // The declared state names no other mod's pane, so the types narrow this event to never.
335 if (e.key === 'pane' && e.plugin !== 'mod-next-steps' && (e as { value?: unknown }).value === true) {
336 $.clock.after(0, async () => {
337 if (!(await isPaneOpen($))) return
338 await $.ui.close({ id: PANE })
339 await $.state.set({ plugin: 'mod-next-steps', key: 'pane' }, false)
340 })
341 }
342
343 return next(e)
344 })
345
346 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
347 await loadLook($)
348 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
349 const { Box, Button, Client, Text } = $.ui.resolve(e)
350 // Toasts sit on top, so a tight band cuts them before anything else.
351 const cards = toastCards({ Box, Client, Text }, toasts, look, text.tipToast)
352 // A command waiting on the person outranks suggestions: the band has few rows, and a hidden hold looks like a hang.
353 const isHolding = (await $.state.get({ plugin: 'mod-blast-radius', key: 'holding' })).value === true
354 // Joins the shared pill row's top section when other mods drew one below; wrapping it would hide the row from mods drawn around this one, which then start a second row.
355 const above = (below: RenderChildren, items: RenderChildren[]) => {
356 const { group, row, rest, tip: menuTip } = splitRow(below)
357 const tip = hovered?.id && hovered !== bandExpired ? tipLine({ Box, Text }, tipText(hovered)) : menuTip
358 // A heads-up card drawn below this mod still goes above the suggestions: it is rarer and more urgent. Without mod-heads-up nothing matches.
359 const isHeadsup = (node: unknown) => keyOf(node).startsWith('headsup-')
360 const ours = items.findIndex(item => keyOf(item).startsWith('nextsteps'))
361 const lifted = rest.filter(isHeadsup)
362 const ordered = ours < 0 ? items : [...items.slice(0, ours), ...lifted, ...items.slice(ours)]
363 const others = ours < 0 ? rest : rest.filter(node => !isHeadsup(node))
364 if (group.length || row.length || tip) return pillRow({ Box, Client, Text }, look.nerd, group, row, [...ordered, ...others], tip, e.props.bodyColumns)
365 return <Box key="nextsteps" flexDirection="column">{items}{below}</Box>
366 }
367 // A prompt sent while the mods below draw clears `steps`, so this frame draws the ones it checked.
368 const shown = steps
369 if (shown.length === 0 || isHolding) {
370 const waiting = enabled && isSuggesting && !isHolding
371 if (cards.length === 0 && !waiting) return next(e)
372 return above(await next(e), [
373 ...cards,
374 waiting ? (
375 <Box key="nextsteps-waiting" paddingX={1}>
376 <Client key="steps-waiting" module="./entry.tsx" height={1} props={{ id: 'steps-waiting', label: text.thinking, isOpen: false, bg: THEME.bg, isBusy: true, spinner: true, still }} />
377 </Box>
378 ) : null,
379 ])
380 }
381 const below = await next(e)
382 const room = Math.max(MIN_ROOM, e.props.bodyColumns - ROW_CHROME)
383 const row = (step: string, index: number) => (
384 <Box key={`steps-line-${index}`} flexDirection="row">
385 {pill({ Box, Text }, look.nerd, THEME.surface2, <Client key={`steps-row-${index}`} module="./entry.tsx" height={1} props={{ id: `steps-row-${index}`, label: fit(step, room), isOpen: false, bg: THEME.surface2, idle: THEME.muted, subLead: String(index + 1), subLeadColor: THEME.muted, nerd: look.nerd, tip: { glyph: bare(look.glyph.go), label: fill(text.tipSuggestionLabel, { n: index + 1 }), text: text.tipSuggestion }, still } satisfies EntryProps} />, `steps-pill-${index}`, cardFill(THEME.border))}
386 <Box key={`steps-hotkey-${index}`} width={0} height={0} overflow="hidden">
387 <Button key={`steps-hotkey-button-${index}`} label={step} hotkey={String(index + 1)} plain onPress={() => fillFrom($, index)} />
388 </Box>
389 </Box>
390 )
391 const dismiss = pill({ Box, Text }, look.nerd, THEME.surface2, <Client key="steps-dismiss" module="./entry.tsx" height={1} props={{ id: 'steps-dismiss', label: bare(look.glyph.close), isOpen: false, bg: THEME.surface2, nerd: look.nerd, tip: { glyph: bare(look.glyph.close), label: text.tipDismissLabel, text: text.tipDismiss }, still } satisfies EntryProps} />, 'steps-dismiss-pill', cardFill(THEME.border))
392
393 return above(below, [
394 ...cards,
395 <Box key="nextsteps-block" flexDirection="column">
396 <Box key="nextsteps-rows" flexDirection="column" paddingX={1} borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(THEME.border)}>
397 <Box key="steps-first" flexDirection="row" justifyContent="space-between" columnGap={1}>
398 {row(shown[0]!, 0)}
399 {dismiss}
400 </Box>
401 {shown.slice(1).map((step, at) => row(step, at + 1))}
402 </Box>
403 {titleEdge({ Box, Text }, look, 'nextsteps', 'prompt', text.cardTitle, text.cardHint, 0)}
404 </Box>,
405 ])
406 })
407
408 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
409 await loadLook($)
410 if (e.requestId !== PANE) return next(e)
411
412 // @ts-expect-error The host's types omit Client from a Pane's elements, yet a Pane draws it.
413 const { Box, Button, Client, Text } = $.ui.resolve(e)
414 const rows = (await $.config.list()).filter(row => row.key.startsWith('mod-next-steps.'))
415 const run = async (action: Action) => {
416 const said = action.command === 'mod-next-steps' ? answer($, action.args ?? '') : Promise.resolve('')
417 note = await said.then(text => `${MARK.ok} ${text || action.label}`).catch(error => `${MARK.warn} ${String(error)}`)
418 $.ui.invalidate('ui.render')
419 }
420
421 return (
422 <Box flexDirection="column" backgroundColor={THEME.bg} paddingX={1} minHeight={e.props.scroll.bodyRows}>
423 {tabRow({ Box, Client, Text }, look.nerd, [{ id: 'mod-next-steps-settings-tab', label: `${text.paneHeading} ${MARK.sep} ${text.paneHeadingSettings}`, icon: look.glyph.gear, isOpen: true, tip: { glyph: look.glyph.gear, label: text.paneHeadingSettings, text: text.tipTabSettings } }], still)}
424 {sepText({ Text }, withMark(look, note) || `${text.statusHint} ${MARK.sep} ${text.statusReload}`, statusColor(note), 'status', { wrap: 'truncate-end' })}
425 {settingsBody({ Box, Button, Client, Text }, look, { rows, actions, working, run, previews, fields, still: still, tips: text.settingTips })}
426 <Box key="footer-space" height={FOOTER_ROWS} flexShrink={0} />
427 {paneFooter({ Box, Text }, hoveredTip(), e.props.bodyColumns, e.props.scroll)}
428 </Box>
429 )
430 })
431}
432hooks/look.tsx 279 lines1// hooks/look.tsx, by v-amorim
2// Shared look; every mod carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import style from './style'
6import type { Tip } from './entry'
7import { CAPS, decodeAll, MARK, mix, THEME, WASH } from './theme'
8
9type Glyphs = Omit<typeof style.glyphs.nerd, 'icon' | 'mark'> & { icon: Record<string, string>; mark: Record<string, string> }
10
11// Safe mode: common Unicode that every modern terminal font draws. Headings and rows carry no icon.
12const SAFE: Glyphs = decodeAll(style.glyphs.safe)
13
14// Nerd mode: Font Awesome, Material and VS Code glyphs from any Nerd Font, controls padded to the same three cells.
15const NERD: Glyphs = decodeAll(style.glyphs.nerd)
16
17export type Look = { nerd: boolean; glyph: typeof SAFE }
18
19export const lookFor = (nerd: boolean): Look => ({ nerd, glyph: nerd ? NERD : SAFE })
20
21// The icons setting every drawing mod declares in its userConfig.
22export type IconMode = 'auto' | 'nerd' | 'safe'
23
24export const terminalSettings = (localAppData: string) => [
25 `${localAppData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
26 `${localAppData}/Microsoft/Windows Terminal/settings.json`,
27]
28
29// Matches "Fira Code Nerd Font Mono" and the patched suffixes NF, NFM and NFP, as in "FiraCodeNFM-Reg".
30export const isNerdName = (face: string) => /nerd|\bNFM?\b/i.test(face) || /NF[MP]?(?![A-Za-z])/.test(face)
31
32export const isNerdFace = (settingsText: string) => [...settingsText.matchAll(/"face"\s*:\s*"([^"]*)"/g)].some(match => isNerdName(match[1]!))
33
34// The variables that name the terminal: WT_SESSION from Windows Terminal, TERM_PROGRAM from programs such as mintty and iTerm2.
35export type TerminalEnv = Partial<
36 Record<'WT_SESSION' | 'TERM_PROGRAM' | 'ITERM_PROFILE' | 'KITTY_WINDOW_ID' | 'ALACRITTY_WINDOW_ID' | 'GNOME_TERMINAL_SCREEN' | 'KONSOLE_VERSION', string | undefined>
37>
38
39// What detection reads: files by path, and the stdout of a command that exits 0 (it throws otherwise).
40export type Host = { localAppData: string; home: string; read: (path: string) => Promise<string>; run: (argv: string[]) => Promise<string> }
41
42// mintty inherits WT_SESSION from a Windows Terminal tab, so only an unset or Windows Terminal TERM_PROGRAM counts.
43export const isWindowsTerminal = ({ WT_SESSION, TERM_PROGRAM }: TerminalEnv) =>
44 Boolean(WT_SESSION) && (!TERM_PROGRAM || /windows\s*terminal/i.test(TERM_PROGRAM))
45
46// Terminals whose font sits in a text config: the first file that names a font wins, and within a file the first pattern.
47const CONFIG_TERMINALS: { is: (env: TerminalEnv) => boolean; files: (host: Host) => string[]; faces: RegExp[] }[] = [
48 {
49 is: env => env.TERM_PROGRAM === 'vscode',
50 files: ({ home, localAppData }) => [
51 `${home}/Library/Application Support/Code/User/settings.json`,
52 `${home}/.config/Code/User/settings.json`,
53 `${localAppData.replace(/\/Local$/, '/Roaming')}/Code/User/settings.json`,
54 ],
55 faces: [/"terminal\.integrated\.fontFamily"\s*:\s*"([^"]*)"/, /"editor\.fontFamily"\s*:\s*"([^"]*)"/],
56 },
57 {
58 is: env => env.TERM_PROGRAM === 'ghostty',
59 files: ({ home }) => [`${home}/.config/ghostty/config`, `${home}/Library/Application Support/com.mitchellh.ghostty/config`],
60 faces: [/^\s*font-family\s*=\s*"?([^"\n#]+)/m],
61 },
62 {
63 is: env => Boolean(env.KITTY_WINDOW_ID),
64 files: ({ home }) => [`${home}/.config/kitty/kitty.conf`],
65 faces: [/^\s*font_family\s+(.+)$/m],
66 },
67 {
68 is: env => env.TERM_PROGRAM === 'WezTerm',
69 files: ({ home }) => [`${home}/.wezterm.lua`, `${home}/.config/wezterm/wezterm.lua`],
70 faces: [/wezterm\.font(?:_with_fallback)?\(\s*\{?\s*["']([^"']+)["']/],
71 },
72 {
73 is: env => Boolean(env.ALACRITTY_WINDOW_ID),
74 files: ({ home }) => [`${home}/.config/alacritty/alacritty.toml`],
75 faces: [/\[font\.normal\][^[]*?family\s*=\s*"([^"]+)"/],
76 },
77]
78
79// iTerm2 keeps its profiles in a plist; plutil prints the part asked for. The profile is the one this window runs, else the default.
80async function itermFace(env: TerminalEnv, host: Host) {
81 const plist = `${host.home}/Library/Preferences/com.googlecode.iterm2.plist`
82 const profiles: { Name?: string; Guid?: string; 'Normal Font'?: string }[] = JSON.parse(await host.run(['plutil', '-extract', 'New Bookmarks', 'json', '-o', '-', plist]))
83 const named = profiles.find(profile => profile.Name === env.ITERM_PROFILE)
84 if (named) return named['Normal Font']
85 const guid = (await host.run(['plutil', '-extract', 'Default Bookmark Guid', 'raw', '-o', '-', plist])).trim()
86 return profiles.find(profile => profile.Guid === guid)?.['Normal Font']
87}
88
89// GNOME Terminal keeps the default profile's font in dconf, and ignores it while the profile uses the system font.
90async function gnomeFace(host: Host) {
91 const unquote = (text: string) => text.trim().replace(/^'|'$/g, '')
92 const schema = `org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:${unquote(await host.run(['gsettings', 'get', 'org.gnome.Terminal.ProfilesList', 'default']))}/`
93 if (unquote(await host.run(['gsettings', 'get', schema, 'use-system-font'])) === 'true') return undefined
94 return unquote(await host.run(['gsettings', 'get', schema, 'font']))
95}
96
97// Konsole names its profile in konsolerc, and the profile file holds `Font=Name,size,...`.
98async function konsoleFace(host: Host) {
99 const profile = /^DefaultProfile=(.+)$/m.exec(await host.read(`${host.home}/.config/konsolerc`))?.[1]?.trim()
100 return profile ? /^Font=([^,\n]+)/m.exec(await host.read(`${host.home}/.local/share/konsole/${profile}`))?.[1] : undefined
101}
102
103async function configFace(terminal: (typeof CONFIG_TERMINALS)[number], host: Host) {
104 for (const path of terminal.files(host)) {
105 const text = await host.read(path).catch(() => '')
106 for (const pattern of terminal.faces) {
107 const face = pattern.exec(text)?.[1]
108 if (face) return face
109 }
110 }
111 return undefined
112}
113
114function terminalFace(env: TerminalEnv, host: Host) {
115 if (env.TERM_PROGRAM === 'iTerm.app') return itermFace(env, host)
116 if (env.GNOME_TERMINAL_SCREEN) return gnomeFace(host)
117 if (env.KONSOLE_VERSION) return konsoleFace(host)
118 const terminal = CONFIG_TERMINALS.find(candidate => candidate.is(env))
119 return terminal ? configFace(terminal, host) : Promise.resolve(undefined)
120}
121
122// Every mod looks up the font on start at once, so a lookup can fail by chance; a third failure throws and the caller keeps its saved answer.
123async function retried<T>(attempt: () => Promise<T>, tries = 3): Promise<T> {
124 for (let left = tries - 1; ; left--) {
125 try {
126 return await attempt()
127 } catch (error) {
128 if (left === 0) throw error
129 }
130 }
131}
132
133// Nerd glyphs only when the terminal this window runs in names a Nerd font; an unknown terminal stays safe, and a lookup that keeps failing throws.
134export async function detectNerd(env: TerminalEnv, host: Host) {
135 if (isWindowsTerminal(env)) {
136 for (const path of terminalSettings(host.localAppData)) {
137 if (isNerdFace(await host.read(path).catch(() => ''))) return true
138 }
139 return false
140 }
141 const face = await retried(() => terminalFace(env, host))
142 return face !== undefined && isNerdName(face)
143}
144
145// `text` led by its icon in Nerd mode; the text alone in safe mode or when the name has no icon.
146export const withIcon = (look: Look, name: string, text: string) => (look.glyph.icon[name] ? `${look.glyph.icon[name]} ${text}` : text)
147
148// A status note with its leading mark (MARK.warn or MARK.ok from theme.ts) drawn as this look's glyph.
149export function withMark(look: Look, note: string) {
150 if (note.startsWith('! ')) return `${look.glyph.warn} ${note.slice(2)}`
151 if (note.startsWith('✓ ')) return `${look.glyph.ok} ${note.slice(2)}`
152 return note
153}
154
155type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
156
157// A pane's title line: the name's icon in Nerd mode, the safe mark otherwise.
158export const paneTitle = ({ Text }: Elements, look: Look, icon: string, text: string) => (
159 <Text key="pane-title" color={THEME.accent} bold>{`${look.glyph.icon[icon] ?? look.glyph.title} ${text}`}</Text>
160)
161
162// One line of text in `color` with every separator dot in the separator colour, since a dot in the text's colour reads as part of the words.
163export function sepText({ Text }: Pick<Elements, 'Text'>, text: string, color: string, key: string, props: Record<string, unknown> = {}) {
164 return (
165 <Text key={key} {...props}>
166 {text.split(MARK.sep).flatMap((part, at) => [
167 ...(at > 0 ? [<Text key={`${key}-sep-${at}`} color={style.colors.pills.separator}>{MARK.sep}</Text>] : []),
168 <Text key={`${key}-part-${at}`} color={color}>{part}</Text>,
169 ])}
170 </Text>
171 )
172}
173
174// The footer rows every pane reserves at its bottom: a rule, then the hovered control's glyph, label and what it does.
175export const FOOTER_ROWS = 2
176
177// A Button cannot post a tip, so its tip is drawn hidden and shown by the hover group the Button's pill shares.
178export type Reveal = { scope: string; tip: Tip }
179
180const tipRow = ({ Box, Text }: Elements, key: string, tip: Tip | null, columns: number, extra: Record<string, unknown> = {}) => (
181 <Box key={key} flexDirection="row" paddingX={1} width={columns} height={1} backgroundColor={THEME.bg} {...extra}>
182 {tip ? <Text color={THEME.accent} backgroundColor={THEME.bg}>{tip.glyph}</Text> : null}
183 {tip ? <Box flexShrink={0}><Text color={THEME.onSurface} backgroundColor={THEME.bg}>{` ${tip.label}`}</Text></Box> : null}
184 {tip ? sepText({ Text }, ` ${MARK.sep} ${tip.text}`, THEME.muted, `${key}-text`, { backgroundColor: THEME.bg, wrap: 'truncate-end' }) : <Text backgroundColor={THEME.bg}> </Text>}
185 </Box>
186)
187
188// Pinned at the pane bottom (scroll offset plus body rows) on its own background, so no row shows through; the row under the rule stays blank with no tip, so nothing jumps.
189export function paneFooter(elements: Elements, tip: Tip | null, columns: number, scroll: { offset: number; bodyRows: number }, reveals: Reveal[] = []) {
190 const { Box, Text } = elements
191 return (
192 <Box key="pane-footer" position="absolute" top={Math.max(0, scroll.offset + scroll.bodyRows - FOOTER_ROWS)} left={0} width={columns} height={FOOTER_ROWS} flexDirection="column" backgroundColor={THEME.bg}>
193 <Text key="footer-rule" color={THEME.border} backgroundColor={THEME.bg}>{MARK.rule.repeat(Math.max(1, columns))}</Text>
194 {tipRow(elements, 'footer-tip', tip, columns)}
195 {reveals.map((reveal, at) =>
196 tipRow(elements, `footer-reveal-${at}`, reveal.tip, columns, { position: 'absolute', top: 1, left: 0, display: 'none', hover: { scope: reveal.scope, display: 'flex' } }),
197 )}
198 </Box>
199 )
200}
201
202// A pill's leading icon, mark or digit, or its count: a rounded subpill washed from `hue` into the `fill` it sits on.
203export function subpill({ Box, Text }: Elements, nerd: boolean, fill: string, text: string, key: string, hue: string = THEME.accent) {
204 const bg = mix(hue, fill, WASH)
205 return (
206 <Box key={key} flexDirection="row" flexShrink={0}>
207 {nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={fill}>{CAPS.left}</Text> : null}
208 <Text key={`${key}-text`} color={hue} backgroundColor={bg} bold>{nerd ? text : ` ${text} `}</Text>
209 {nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={fill}>{CAPS.right}</Text> : null}
210 </Box>
211 )
212}
213
214// A section's heading: a surface3 pill led by its icon as a subpill in Nerd mode, bold accent text in safe mode.
215export function heading(elements: Elements, look: Look, key: string, icon: string, title: string) {
216 const { Box, Text } = elements
217 if (!look.nerd) {
218 return (
219 <Box key={`${key}-head`}>
220 <Text color={THEME.accent} bold>{title}</Text>
221 </Box>
222 )
223 }
224 const glyph = look.glyph.icon[icon]
225 return (
226 <Box key={`${key}-head`} flexDirection="row">
227 <Text color={THEME.surface3}>{CAPS.left}</Text>
228 <Box key={`${key}-head-body`} flexDirection="row" backgroundColor={THEME.surface3}>
229 {glyph ? subpill(elements, true, THEME.surface3, glyph, `${key}-head-icon`) : null}
230 <Text color={THEME.onSurface} bold>{glyph ? ` ${title}` : title}</Text>
231 </Box>
232 <Text color={THEME.surface3}>{CAPS.right}</Text>
233 </Box>
234 )
235}
236
237// A card's fill: surface1 washed lightly toward its hue, under a neutral border. Pills on the card sit on it.
238export const cardFill = (hue: string = THEME.accent) => mix(THEME.surface1, hue, 0.06)
239
240// The row above a card, painted on bg so a scrolled card above never shows through the gap.
241export function cardGap({ Box }: Elements, key: string, bg: string = THEME.bg) {
242 return <Box key={`${key}-gap`} height={1} flexShrink={0} backgroundColor={bg} />
243}
244
245// A card's title laid over its top border after the corner (`top` is the border's row); Pane refuses borderTop, so the border stays whole underneath.
246export function titleEdge(elements: Elements, look: Look, key: string, icon: string, title: string, hint = '', top = 1, edge: string = THEME.border) {
247 const { Box, Text } = elements
248 const glyph = look.nerd ? look.glyph.icon[icon] : undefined
249 return (
250 <Box key={`${key}-edge`} position="absolute" top={top} left={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
251 <Text color={edge}>{`${MARK.rule} `}</Text>
252 {glyph ? <Text color={THEME.accent}>{`${glyph} `}</Text> : null}
253 {sepText(elements, title, THEME.onSurface, `${key}-title`, { bold: true })}
254 {hint ? sepText(elements, ` ${hint}`, THEME.muted, `${key}-hint`) : null}
255 <Text> </Text>
256 </Box>
257 )
258}
259
260// A round card tinted toward `hue`, its title (and muted `hint`) set into the top edge, `right` (such as Fold all) at that edge's end.
261export function section(elements: Elements, look: Look, key: string, icon: string, title: string, body: unknown, hue: string = THEME.accent, { hint = '', right }: { hint?: string; right?: unknown } = {}) {
262 const { Box } = elements
263 return (
264 <Box key={`${key}-block`} flexDirection="column">
265 {cardGap(elements, key)}
266 <Box key={key} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(hue)} paddingX={1}>
267 {body}
268 </Box>
269 {title ? titleEdge(elements, look, key, icon, title, hint) : null}
270 {right ? (
271 <Box key={`${key}-edge-right`} position="absolute" top={1} right={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
272 {right}
273 </Box>
274 ) : null}
275 </Box>
276 )
277}
278
279hooks/settings.tsx 297 lines1// hooks/settings.tsx, by v-amorim
2// Shared settings body; every mod carries the same copy.
3import type { ConfigRow, ConfigValue, RenderElement } from 'claude-code'
4
5import type { EntryProps, Tip } from './entry'
6import { cardFill, cardGap, section, sepText, type Look, type Reveal } from './look'
7import style from './style'
8import { CAPS, fill, MARK, THEME } from './theme'
9
10// A button a mod offers beside its settings: runs `/<command> <args>`, declared in its showcase.json.
11// isLocal marks the drawing mod's own command, which it calls directly, so Claude working does not hold it.
12export type Action = { label: string; command: string; args?: string; isLocal?: boolean }
13
14type Elements = { Box: (props: any) => RenderElement; Button: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
15
16export type SettingsProps = {
17 // The mod's own /config rows (`<plugin>.<field>`), as $.config.list() returns them.
18 rows: ConfigRow[]
19 actions: Action[]
20 // Another mod's command run while Claude works only queues as a prompt, so those actions go inert.
21 working: boolean
22 run: (action: Action) => unknown
23 // The animations setting is off: a clicked control does not shimmer.
24 still?: boolean
25 // Hover tips by setting field (the key after the dot); a setting without one shows its description's first sentence.
26 tips?: Record<string, string>
27 // The mod's example cards, from its showcase.json, so its own panel and mod-manager draw the same ones.
28 previews?: Previews
29 // The mod's plugin.json userConfig, read by its own panel and by mod-manager alike, for the min and max a number setting declares.
30 fields?: Record<string, Range>
31 // Text settings (by field) another mod edits in its own panel: the card shows a pill running that panel's command in place of "edit it in /config".
32 editors?: Record<string, Action>
33}
34
35export type Range = { min?: number; max?: number }
36
37// A title's trailing "(unit)" is what its number reads in; the head drops it and the value carries it.
38export function splitTitle(label: string) {
39 const match = /^(.*?)\s*\(([^()]+)\)$/.exec(label)
40 return match ? { name: match[1]!, unit: match[2]! } : { name: label, unit: '' }
41}
42
43// Large round counts read short, in thousands or millions.
44const short = (n: number) => (n >= 1_000_000 && n % 100_000 === 0 ? `${n / 1_000_000}M` : n >= 10_000 && n % 1000 === 0 ? `${n / 1000}K` : String(n))
45
46export function showNumber(value: number, unit: string) {
47 if (!unit) return String(value)
48 if (unit === '%') return `${value}%`
49 return `${short(value)} ${value === 1 ? unit.replace(/s$/, '') : unit}`
50}
51
52// About twenty clicks from one end of the range to the other, on a 1, 2 or 5 step; no range steps by one.
53export function stepOf({ min, max }: Range = {}) {
54 if (min === undefined || max === undefined || max <= min) return 1
55 const raw = (max - min) / 20
56 const power = 10 ** Math.floor(Math.log10(raw))
57 const nearest = [1, 2, 5, 10].map(factor => factor * power).reduce((best, step) => (Math.abs(step - raw) < Math.abs(best - raw) ? step : best))
58 return Math.max(1, nearest)
59}
60
61export function stepNumber(value: number, by: 1 | -1, range: Range = {}) {
62 return Math.min(range.max ?? Infinity, Math.max(range.min ?? -Infinity, value + by * stepOf(range)))
63}
64
65type ThemeColor = keyof typeof THEME
66
67// A literal run of an example line, as the mod's previews test drew it from its real tree; a color is a theme name or a #hex.
68export type PreviewSpan = { text: string; color?: string; bg?: string; bold?: boolean }
69export type PreviewLine = { spans: PreviewSpan[] }
70export type Preview = { frame?: { border: string; bg: string }; nerd: PreviewLine[]; safe: PreviewLine[] }
71
72// showcase.json's "previews", written by `just previews`: per setting field, per value as text ("true", "false", an option), the card in each icon mode.
73export type Previews = Record<string, Record<string, Preview>>
74
75const shade = (color: string | undefined) => (color === undefined ? undefined : color in THEME ? THEME[color as ThemeColor] : color)
76
77// What the row's current value looks like, as a card under it; null when the mod drew none for that value.
78function exampleOf({ Box, Text }: Elements, look: Look, previews: Previews | undefined, row: ConfigRow, index: number) {
79 const entry = previews?.[row.key.slice(row.key.indexOf('.') + 1)]?.[String(row.value)]
80 const lines = look.nerd ? entry?.nerd : entry?.safe
81 if (!entry || !lines?.length) return null
82 // The lines carry their own padding and strip, so the frame adds none. The example sinks to bg, so it never matches the card it sits in.
83 const sunk = (color: string | undefined) => (color !== undefined && color === shade(entry.frame?.bg) ? THEME.bg : color)
84 return (
85 <Box key={`setting-preview-${index}`} flexDirection="column" borderStyle="round" borderColor={shade(entry.frame?.border) ?? THEME.border} backgroundColor={THEME.bg} marginTop={1}>
86 {lines.map((line, index) => (
87 <Text key={`line-${index}`} wrap="truncate-end">
88 {line.spans.length === 0 ? ' ' : line.spans.map((span, at) => <Text key={`span-${at}`} color={shade(span.color) ?? THEME.onSurface} {...(span.bg ? { backgroundColor: sunk(shade(span.bg)) } : {})} {...(span.bold ? { bold: true } : {})}>{span.text}</Text>)}
89 </Text>
90 ))}
91 </Box>
92 )
93}
94
95// The value as the panel shows it: on/off as words, a list as its items.
96const shown = (row: ConfigRow) =>
97 row.kind === 'boolean' ? (row.value === true ? style.text.on : style.text.off) : Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
98
99// Words that stay capitalized when a description follows a tip's label and colon.
100const PROPER = new Set(['Claude', 'Haiku', 'Opus', 'Sonnet', 'Windows', 'Nerd'])
101
102// A setting's description as the tip after "Label: ": its first sentence, led by a lowercase letter unless that word is a proper noun.
103export function descriptionTip(description: string) {
104 const sentence = description.split('. ')[0]!.replace(/\.$/, '')
105 const word = /^[A-Z][a-z]+\b/.exec(sentence)?.[0]
106 return word && !PROPER.has(word) ? sentence[0]!.toLowerCase() + sentence.slice(1) : sentence
107}
108
109// A control is an Entry pill posting `setting:<key>=<value as JSON>`; the mod's ui.message hook saves it with settingChoice.
110const SETTING = 'setting:'
111const settingId = (row: ConfigRow, value: ConfigValue) => `${SETTING}${row.key}=${JSON.stringify(value)}`
112
113// The change a clicked settings control asks for, or null when the message is something else.
114export function settingChoice(data: unknown): { key: string; value: ConfigValue } | null {
115 const message = data as { type?: string; id?: string } | undefined
116 if (message?.type !== 'menu-entry' || !message.id?.startsWith(SETTING)) return null
117 const rest = message.id.slice(SETTING.length)
118 const at = rest.indexOf('=')
119 if (at === -1) return null
120 try {
121 return { key: rest.slice(0, at), value: JSON.parse(rest.slice(at + 1)) as ConfigValue }
122 } catch {
123 return null
124 }
125}
126
127// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[↻]`).
128const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
129
130// A pill on the card's tint, capped where the font has the caps; `hover` lights the caps with a Button scoped to the pill's key.
131const pillOn = ({ Box, Text }: Elements, look: Look, key: string, bg: string, body: unknown, on = THEME.surface1, hover?: string) => {
132 const lit = hover ? { hover: { scope: key, color: hover } } : {}
133 return (
134 <Box key={key} flexDirection="row" flexShrink={0}>
135 {look.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={on} {...lit}>{CAPS.left}</Text> : null}
136 {body}
137 {look.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={on} {...lit}>{CAPS.right}</Text> : null}
138 </Box>
139 )
140}
141
142export type Choice = { key: string; isOn: boolean; props: Pick<EntryProps, 'id' | 'label' | 'still' | 'tip' | 'subLead' | 'subLeadColor'> }
143
144// A control track: a surface3 pill on `on`, padded by a space each side in safe mode, where it has no caps.
145function track(elements: Elements, look: Look, key: string, on: string, parts: unknown[]) {
146 const { Box, Text } = elements
147 const space = (side: string) => (look.nerd ? null : <Text key={`${key}-space-${side}`} backgroundColor={THEME.surface3}> </Text>)
148 return pillOn(elements, look, `${key}-bar`, THEME.surface3, <Box key={`${key}-options`} flexDirection="row" backgroundColor={THEME.surface3}>{[space('left'), ...parts, space('right')]}</Box>, on)
149}
150
151// A choice in a track: the pick a lifted (surface-4) subpill in primary-70 (`ok`), the rest muted; `on` is what the track sits on.
152export function choiceBar(elements: Elements, look: Look, key: string, on: string, choices: Choice[]) {
153 const { Client } = elements
154 const entry = (choice: Choice, shape: Partial<EntryProps>) => (
155 <Client key={choice.key} module="./entry.tsx" height={1} props={{ ...choice.props, isOpen: choice.isOn, bg: THEME.surface3, ...shape } satisfies EntryProps} />
156 )
157 const options = choices.map(choice =>
158 choice.isOn
159 ? // Tight, so the subpill is as wide as an idle option: the caps (Nerd) or a space (safe) stand where its padding was.
160 pillOn(elements, look, `${choice.key}-chosen`, THEME.lifted, entry(choice, { bg: THEME.lifted, color: THEME.ok, idle: THEME.muted, nerd: look.nerd, tight: true }), THEME.surface3)
161 : entry(choice, { idle: THEME.muted }),
162 )
163 return track(elements, look, key, on, options)
164}
165
166const actionScope = (index: number) => `action-row-${index}`
167
168// The tip each live action shows in the pane footer on hover; a Button cannot post one, so paneFooter reveals it by the pill's hover group.
169export const actionReveals = (actions: Action[], look: Look, working: boolean): Reveal[] =>
170 actions.flatMap((action, index) =>
171 working && !action.isLocal ? [] : [{ scope: actionScope(index), tip: { glyph: bare(look.glyph.go), label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }],
172 )
173
174const editorScope = (index: number) => `editor-row-${index}`
175
176const fieldOf = (row: ConfigRow) => row.key.slice(row.key.indexOf('.') + 1)
177
178// The footer tip of each live editor pill, like actionReveals.
179export const editorReveals = (rows: ConfigRow[], editors: Record<string, Action>, look: Look, working: boolean): Reveal[] =>
180 rows.flatMap((row, index) => {
181 const action = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
182 return action && !working ? [{ scope: editorScope(index), tip: { glyph: MARK.open, label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }] : []
183 })
184
185// A pill with a plain Button so the press is a real one; the glyph sits in the label so the whole pill lights.
186const buttonPill = (elements: Elements, look: Look, scope: string, key: string, glyph: string, label: string, onPress: () => unknown) => {
187 const { Button } = elements
188 return pillOn(
189 elements,
190 look,
191 scope,
192 THEME.surface3,
193 <Button key={key} label={look.nerd ? `${glyph} ${label}` : ` ${glyph} ${label} `} plain hover={{ scope, color: THEME.accent, inverse: true }} onPress={onPress} />,
194 THEME.surface1,
195 THEME.accent,
196 )
197}
198
199// Edits the row in place: on/off flips, a choice is a bar, a number steps; text is edited in /config.
200function control(elements: Elements, look: Look, row: ConfigRow, index: number, still: boolean, tips?: Record<string, string>, fields?: Record<string, Range>) {
201 const { Client, Text } = elements
202 const key = `setting-${index}`
203 const text = tips?.[row.key.slice(row.key.indexOf('.') + 1)] ?? (row.description ? descriptionTip(row.description) : '')
204 // Client props refuse an undefined value, so no tip text means no key.
205 const tipFor = (glyph: string) => (text ? { tip: { glyph: bare(glyph), label: splitTitle(row.label).name, text } satisfies Tip } : {})
206 const entry = (suffix: string, label: string, value: ConfigValue, bg: string, color: string, isOpen: boolean, glyph = look.glyph.cycle) => (
207 <Client key={`${key}-${suffix}`} module="./entry.tsx" height={1} props={{ id: settingId(row, value), label, isOpen, bg, color, idle: color, still, ...tipFor(glyph) } satisfies EntryProps} />
208 )
209 if (row.isLocked) return <Text color={THEME.muted}>{`${shown(row)} ${bare(look.glyph.locked)} ${style.text.locked}`}</Text>
210 if (row.kind === 'boolean') {
211 // The switch: `━━●` on, `●━━` off, the knob a bare glyph joined to the line.
212 const isOn = row.value === true
213 const shape = isOn
214 ? { lead: MARK.track, leadColor: THEME.accent, label: MARK.knob, idle: THEME.ok }
215 : { lead: MARK.knob, leadColor: style.colors.pills.readout, label: MARK.track, idle: THEME.trackOff }
216 const flip = <Client key={`${key}-flip`} module="./entry.tsx" height={1} props={{ id: settingId(row, !isOn), isOpen: false, bg: THEME.surface3, tight: true, still, ...shape, ...tipFor(look.glyph.cycle) } satisfies EntryProps} />
217 return track(elements, look, key, cardFill(), [flip])
218 }
219 if (row.kind === 'choice' && row.options?.length) {
220 const choices = row.options.map((option, at) => ({ key: `${key}-option-${at}`, isOn: option === String(row.value), props: { id: settingId(row, option), label: option, still, ...tipFor(look.glyph.cycle) } }))
221 return choiceBar(elements, look, key, cardFill(), choices)
222 }
223 if (row.kind === 'number' && typeof row.value === 'number') {
224 const range = fields?.[row.key.slice(row.key.indexOf('.') + 1)]
225 const value = <Text key={`${key}-value`} color={THEME.ok} backgroundColor={THEME.lifted} bold>{look.nerd ? showNumber(row.value, splitTitle(row.label).unit) : ` ${showNumber(row.value, splitTitle(row.label).unit)} `}</Text>
226 return track(elements, look, key, cardFill(), [
227 entry('down', bare(look.glyph.down), stepNumber(row.value, -1, range), THEME.surface3, THEME.muted, false, look.glyph.down),
228 pillOn(elements, look, `${key}-value-pill`, THEME.lifted, value, THEME.surface3),
229 entry('up', bare(look.glyph.up), stepNumber(row.value, 1, range), THEME.surface3, THEME.muted, false, look.glyph.up),
230 ])
231 }
232 return <Text color={THEME.secondary}>{shown(row)}</Text>
233}
234
235// A setting's first row: the label bold on the left, its control anchored right.
236export const settingHead = ({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, control: unknown) => (
237 <Box key={key} flexDirection="row" columnGap={2} justifyContent="space-between">
238 <Text color={THEME.onSurface} bold>{label}</Text>
239 {control}
240 </Box>
241)
242
243// A hairline across its box, under a setting's head row; a Client, since only a Client learns the width it gets.
244export const hairline = ({ Client }: Pick<Elements, 'Client'>, key: string, bg: string) => (
245 <Client key={key} module="./entry.tsx" width="100%" height={1} props={{ id: key, label: '', isOpen: false, bg, rule: true } satisfies EntryProps} />
246)
247
248// The Settings and Actions sections of a mod's panel or page: one round-bordered card per setting.
249export function settingsBody(elements: Elements, look: Look, { rows, actions, working, run, previews, tips, fields, editors = {}, still = false }: SettingsProps) {
250 const { Box, Text } = elements
251 const settings = [
252 rows.length === 0 ? <Text key="settings-none" color={THEME.muted}>{style.text.noSettings}</Text> : null,
253 ...rows.map((row, index) => {
254 const example = exampleOf(elements, look, previews, row, index)
255 const editor = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
256 const head = editor && !working ? (
257 <Box key={`setting-control-${index}`} flexDirection="row" columnGap={1}>
258 {control(elements, look, row, index, still, tips, fields)}
259 {buttonPill(elements, look, editorScope(index), `setting-editor-${index}`, MARK.open, editor.label, () => run(editor))}
260 </Box>
261 ) : (
262 control(elements, look, row, index, still, tips, fields)
263 )
264 const hint = row.kind !== 'text' ? '' : editor ? (working ? style.text.idleOnly : '') : style.text.editInConfig
265 const note = [row.description, hint].filter(Boolean).join(` ${MARK.sep} `)
266 return (
267 <Box key={`setting-row-${index}`} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1} marginTop={index === 0 ? 0 : 1}>
268 {settingHead(elements, `setting-head-${index}`, splitTitle(row.label).name, head)}
269 {row.description || example ? hairline(elements, `setting-rule-${index}`, cardFill()) : null}
270 {row.description ? (
271 sepText(elements, note, THEME.muted, `setting-text-${index}`)
272 ) : null}
273 {example}
274 </Box>
275 )
276 }),
277 ]
278 const buttons = actions.map((action, index) =>
279 working && !action.isLocal ? (
280 sepText(elements, `${look.glyph.go} ${action.label} ${MARK.sep} ${style.text.idleOnly}`, THEME.muted, `action-${index}`)
281 ) : (
282 buttonPill(elements, look, actionScope(index), `action-${index}`, bare(look.glyph.go), action.label, () => run(action))
283 ),
284 )
285 return (
286 <Box key="settings" flexDirection="column">
287 {/* The cards carry their own border, and the panel's open Settings tab already names them. */}
288 <Box key="settings-cards" flexDirection="column">
289 {cardGap(elements, 'settings')}
290 {settings}
291 </Box>
292 {actions.length > 0 ? section(elements, look, 'actions', 'actions', style.text.actions, buttons) : null}
293 </Box>
294 )
295}
296
297hooks/theme.ts 46 lines1// hooks/theme.ts, by v-amorim
2// Shared theme; every mod carries the same copy. Colors, marks and glyphs live in style.ts.
3import style from './style'
4
5export const THEME = style.colors.theme
6
7// Shell command roles: what runs, its options, quoted text, variables, numbers and the glue between steps.
8export const SYNTAX = style.colors.syntax
9
10// One cell each and never an emoji: ⚠ switches to its emoji form in some fonts and draws two cells wide.
11export const MARK = style.marks
12
13// style.ts writes Nerd glyphs as {U+XXXX}, since private-use characters do not survive many editors and tools.
14const decode = (text: string) => text.replace(/\{U\+([0-9A-F]+)\}/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
15
16export function decodeAll<T>(value: T): T {
17 if (typeof value === 'string') return decode(value) as T
18 if (Array.isArray(value)) return value.map(decodeAll) as T
19 return Object.fromEntries(Object.entries(value as object).map(([key, inner]) => [key, decodeAll(inner)])) as T
20}
21
22// A pill's rounded ends, drawn only where the font has them.
23export const CAPS = decodeAll(style.caps)
24
25export const statusColor = (note: string) =>
26 note.startsWith(MARK.ok) ? THEME.good : note.startsWith(MARK.warn) ? THEME.alert : THEME.muted
27
28const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
29const isHex = (color: string) => /^#[0-9a-f]{6}$/i.test(color)
30
31// The color `t` of the way from `from` to `to`; a color that is not #RRGGBB switches halfway.
32export function mix(from: string, to: string, t: number) {
33 if (t <= 0) return from
34 if (t >= 1) return to
35 if (!isHex(from) || !isHex(to)) return t < 0.5 ? from : to
36 const end = channels(to)
37 return `#${channels(from).map((value, at) => Math.round(value + (end[at]! - value) * t).toString(16).padStart(2, '0')).join('')}`
38}
39
40// The share of a hue left in a subpill or count badge, washed into the fill it sits on.
41export const WASH = 0.72
42
43// A text.ts string with its {name} slots filled; a slot with no value stays as written, so a missing one shows.
44export const fill = (template: string, values: Record<string, string | number> = {}) =>
45 template.replace(/\{(\w+)\}/g, (slot, name: string) => (name in values ? String(values[name]) : slot))
46hooks/entry.tsx 241 lines1// hooks/entry.tsx, by v-amorim
2// Shared pill entry; every mod that draws a menu entry carries the same copy.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import style from './style'
6import { CAPS, mix, THEME, WASH } from './theme'
7
8// color: open text (a red Run); idle: closed text, muted unless set; isBusy shimmers the label until the click's result lands.
9// still: the mod's animations setting is off, so nothing shimmers and the hover color switches at once.
10// lead: a bare glyph before the label in leadColor (after a space, none when tight: the cap is the gap); subLead: an icon before it as a rounded subpill in subLeadColor;
11// badge: a count after it in its own pill. Subpills are capped where `nerd` has the caps.
12// tight: no label padding; tip: posts `tip` instead of `menu-hover`; rail: a divider (a Text between Clients squeezes them); rule: a full-width hairline.
13// spinner: the braille dots lead the label in accent while work runs (write the label without its …); still holds the first frame.
14export type EntryProps = {
15 id: string
16 label: string
17 isOpen: boolean
18 bg: string
19 color?: string
20 idle?: string
21 isBusy?: boolean
22 still?: boolean
23 lead?: string
24 leadColor?: string
25 subLead?: string
26 subLeadColor?: string
27 badge?: { text: string; color: string; bg: string }
28 nerd?: boolean
29 tight?: boolean
30 tip?: Tip
31 rail?: boolean
32 rule?: boolean
33 spinner?: boolean
34 compact?: boolean // false: the pill row may cut the entry to its lead when the row is wider than the band
35}
36
37const entryText = (props: EntryProps) => {
38 const pad = props.tight ? (props.isOpen && !props.nerd ? ' ' : '') : ' '
39 return `${pad}${props.compact && props.lead ? '' : props.label}${props.tight && props.badge ? ' ' : pad}`
40}
41
42// The cells an entry takes; subpills add their two caps, or their two padding spaces in safe mode.
43export const entryCells = (props: EntryProps) => {
44 if (props.rail) return 1
45 const cells = (text: string) => [...text].length
46 const leadText = props.lead ?? (props.spinner ? SPIN_FRAMES[0]! : '')
47 const lead = leadText ? cells(leadText) + (props.tight ? 0 : 1) : 0
48 return lead + (props.subLead ? cells(props.subLead) + 2 : 0) + cells(entryText(props)) + (props.badge ? cells(props.badge.text) + 2 : 0)
49}
50
51// A hovered control's glyph, label and what it does; the pane footer and the band's tip row both show it.
52export type Tip = { glyph: string; label: string; text: string }
53
54// Posted to the owning mod's ui.message when the pointer enters a control (id set) or leaves it (id null).
55export type TipMessage = { type: 'tip'; id: string | null; glyph?: string; label?: string; text?: string }
56
57export const isTipMessage = (data: unknown): data is TipMessage => (data as TipMessage | undefined)?.type === 'tip'
58
59// The band's tip row text for a tip message: "Label: what it does", or '' once the pointer left.
60export const tipText = (message: TipMessage | null | undefined) => (message?.id && message.label ? `${message.label}: ${message.text ?? ''}` : '')
61
62// A control calls this from onPointer, with its tip on enter and null on leave; never also `menu-hover`, one post per frame lands.
63export const postTip = (surface: { post: (data: any) => void }, id: string, tip: Tip | null) => {
64 surface.post((tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }) satisfies TipMessage)
65}
66
67// menu-hover tells the owning mod the pointer arrived or left, so it can show its tip under the row.
68export type EntryMessage = { type: 'menu-entry'; id: string } | { type: 'menu-hover'; id: string; isOver: boolean }
69
70// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
71const HOVER_QUIET_MS = 5000
72
73export const TICK_MS = 140
74
75// The loading shimmer: a soft primary-70 band sweeps the dim text, then a pause.
76const BAND = style.colors.shimmer
77const SWEEP_PAUSE = 8
78
79export const shimmerColor = (at: number, phase: number, length: number) => {
80 const period = length + BAND.length * 2 + SWEEP_PAUSE
81 const head = (phase % period) - BAND.length
82 return BAND[Math.abs(at - head)] ?? style.colors.pills.readout
83}
84
85// The braille dots spinner, one frame every SPIN_MS, in accent.
86export const SPIN_FRAMES = [...style.marks.spinner]
87export const SPIN_MS = 80
88
89// The pointer lights a control at once and its color fades out over this many steps, one every HOVER_TICK_MS.
90export const HOVER_STEPS = 2
91
92export const HOVER_TICK_MS = 60
93
94export { mix }
95
96// One step of a hover fade: full at once while the pointer is on, one step toward 0 once it left.
97export const stepToward = (level: number, isOver: boolean) => (isOver ? HOVER_STEPS : Math.max(0, level - 1))
98
99// Even steps: an eased last step barely moves the color, so the fade seemed to hang at its end.
100export const hoverMix = (from: string, to: string, level: number) => mix(from, to, level / HOVER_STEPS)
101
102// A click shimmers the entry until the mod answers with new props, or this long at most.
103const PRESS_MS = 1200
104
105// pressed: the props a click saw and when it came, cleared once the mod answers.
106type State = { isOver: boolean; at: number; phase: number; level?: number; spin?: number; pressed?: { props: string; at: number } }
107
108const isPressed = (state: State | undefined, props: EntryProps | undefined) =>
109 Boolean(state?.pressed && Date.now() - state.pressed.at < PRESS_MS && state.pressed.props === JSON.stringify(props))
110
111// Each instance's running fade-out timer, started by the leave event and stopped at 0 or when the pointer returns.
112const fades = new WeakMap<object, () => void>()
113
114const startFade = (surface: ClientSurface<State>) => {
115 if (fades.has(surface)) return
116 const stop = surface.every(HOVER_TICK_MS, () => {
117 const { state } = surface
118 if (!state) return
119 const level = stepToward(state.level ?? 0, state.isOver)
120 if (level !== (state.level ?? 0)) surface.setState({ ...state, level })
121 if (state.isOver || level === 0) {
122 stop()
123 fades.delete(surface)
124 }
125 })
126 fades.set(surface, stop)
127}
128
129// The ticker reads the latest props from here, since its closure keeps the first ones.
130const latest = new WeakMap<object, EntryProps>()
131
132// Each instance's running tick: it runs only while a shimmer moves or a hover is watched, so idle pills cost no wakeups.
133const ticks = new WeakMap<object, () => void>()
134
135const startTick = (surface: ClientSurface<State>) => {
136 if (ticks.has(surface)) return
137 const stop = surface.every(TICK_MS, () => {
138 const { state } = surface
139 if (!state) return
140 const now = latest.get(surface)
141 if (!now?.still && (now?.isBusy || isPressed(state, now))) surface.setState({ ...state, phase: state.phase + 1 })
142 else if (state.pressed) surface.setState({ ...state, pressed: undefined })
143 else if (state.isOver && Date.now() - state.at >= HOVER_QUIET_MS) {
144 surface.setState({ ...state, isOver: false, at: Date.now() })
145 if (!now?.still) startFade(surface)
146 const id = now?.id ?? ''
147 if (now?.tip) postTip(surface, id, null)
148 else surface.post({ type: 'menu-hover', id, isOver: false } satisfies EntryMessage)
149 } else if (!state.isOver) {
150 stop()
151 ticks.delete(surface)
152 }
153 })
154 ticks.set(surface, stop)
155}
156
157// Each spinning instance's frame clock, stopped once the entry stops spinning.
158const spins = new WeakMap<object, () => void>()
159
160const startSpin = (surface: ClientSurface<State>) => {
161 if (spins.has(surface)) return
162 const stop = surface.every(SPIN_MS, () => {
163 const { state } = surface
164 const now = latest.get(surface)
165 if (!state || !now?.spinner || now.still) {
166 stop()
167 spins.delete(surface)
168 return
169 }
170 surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
171 })
172 spins.set(surface, stop)
173}
174
175// A Button inverts under the pointer, so the entry is plain text that lights up like a sidebar row.
176const Entry: ClientModule<EntryProps, State> = (props, surface) => {
177 const { Box, Text } = surface.elements
178 if (props.rail) return <Text color={style.colors.pills.separator} backgroundColor={props.bg}>{style.marks.rail}</Text>
179 if (props.rule) return <Text color={THEME.border} backgroundColor={props.bg}>{style.marks.rule.repeat(Math.max(1, surface.columns))}</Text>
180 const hovered = surface.state?.isOver === true
181 latest.set(surface, props)
182
183 if (!surface.state) surface.setState({ isOver: false, at: 0, phase: 0 })
184 if (props.isBusy && !props.still) startTick(surface)
185 if (props.spinner && !props.still) startSpin(surface)
186
187 surface.onPointer(event => {
188 startTick(surface)
189 const over = event.type !== 'leave'
190 if (over !== hovered) {
191 if (props.tip) postTip(surface, props.id, over ? props.tip : null)
192 else surface.post({ type: 'menu-hover', id: props.id, isOver: over } satisfies EntryMessage)
193 }
194 const isClick = event.type === 'down' && event.button === 'left'
195 const base = surface.state ?? { phase: 0, isOver: false, at: 0 }
196 const level = over === base.isOver ? base.level : over ? HOVER_STEPS : props.still ? 0 : base.level
197 if (!over && !props.still) startFade(surface)
198 surface.setState({ ...base, isOver: over, at: Date.now(), level, pressed: isClick ? { props: JSON.stringify(props), at: Date.now() } : base.pressed })
199 if (isClick) surface.post({ type: 'menu-entry', id: props.id } satisfies EntryMessage)
200 })
201
202 const text = entryText(props)
203 const isShimmering = !props.still && (props.isBusy || isPressed(surface.state, props))
204 const phase = surface.state?.phase ?? 0
205 const label = isShimmering ? (
206 <Box key="label" flexDirection="row">
207 {[...text].map((char, at) => (
208 <Text key={`c${at}`} color={shimmerColor(at, phase, text.length)} backgroundColor={props.bg}>
209 {char}
210 </Text>
211 ))}
212 </Box>
213 ) : (
214 <Text key="label" color={hoverMix(props.isOpen ? (props.color ?? THEME.onSurface) : (props.idle ?? THEME.muted), THEME.accent, surface.state?.level ?? 0)} bold={props.isOpen && Boolean(props.idle)} backgroundColor={props.bg}>
215 {text.split(style.marks.sep).flatMap((part, at) => (at === 0 ? [part] : [<Text key={`sep-${at}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
216 </Text>
217 )
218 const lead = props.spinner ? SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length] : props.lead
219 if (!lead && !props.subLead && !props.badge) return isShimmering ? <Box flexDirection="row">{label}</Box> : label
220
221 const sub = (key: string, text: string, color: string, bg: string) => (
222 <Box key={key} flexDirection="row">
223 {props.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={props.bg}>{CAPS.left}</Text> : null}
224 <Text key={`${key}-text`} color={color} backgroundColor={bg} bold>{props.nerd ? text : ` ${text} `}</Text>
225 {props.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={props.bg}>{CAPS.right}</Text> : null}
226 </Box>
227 )
228 const subHue = props.subLeadColor ?? THEME.accent
229 const { badge } = props
230 return (
231 <Box flexDirection="row">
232 {lead ? <Text key="lead" color={props.spinner ? THEME.accent : (props.leadColor ?? THEME.accent)} backgroundColor={props.bg}>{props.tight ? lead : ` ${lead}`}</Text> : null}
233 {props.subLead ? sub('sub-lead', props.subLead, subHue, mix(subHue, props.bg, WASH)) : null}
234 {label}
235 {badge ? sub('badge', badge.text, badge.color, badge.bg) : null}
236 </Box>
237 )
238}
239
240export default Entry
241hooks/pills.tsx 167 lines1// hooks/pills.tsx, by v-amorim
2// Shared pill row; every mod that draws in the row carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, mix, type EntryProps, type Tip } from './entry'
6import style from './style'
7import { CAPS, decodeAll, THEME, WASH } from './theme'
8
9// The menu group on Moonlight surface-3, readouts on surface-2.
10export const PILL = { primary: style.colors.pills.primary, secondary: style.colors.pills.secondary }
11
12// Menu entries left to right, by the id in their `menu-pad-<id>` Box key; an unknown id goes last.
13const ENTRY_ORDER = ['manager', 'skills', 'prompts', 'filetree', 'progress', 'headsup']
14
15type Node = { props?: Record<string, unknown>; children?: unknown[] } | null | undefined
16type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
17
18const keyOf = (node: unknown) => (node as Node)?.props?.key as string | undefined
19const childrenOf = (node: unknown) => (node as Node)?.children ?? []
20
21// Splits next(e) into the menu entries other mods drew, the rest of the row, what else the band holds, and the hover tip under the row.
22export function splitRow(others: unknown) {
23 if (keyOf(others) !== 'menu-rows') return { group: [] as unknown[], row: [] as unknown[], rest: [others], tip: null as unknown }
24 const children = childrenOf(others)
25 const rest = children.filter(child => keyOf(child) !== 'menu-row' && keyOf(child) !== 'menu-tip')
26 const pills = childrenOf(children.find(child => keyOf(child) === 'menu-row'))
27 const groupPill = pills.find(child => keyOf(child) === 'menu-group')
28 const body = childrenOf(groupPill).find(child => keyOf(child) === 'menu-group-body')
29 return {
30 // A mod still running an older copy may draw `menu-group-sep-*` separators between entries; the group drops them.
31 group: childrenOf(body).filter(child => !keyOf(child)?.startsWith('menu-group-sep')),
32 row: pills.filter(child => keyOf(child) !== 'menu-group'),
33 rest,
34 tip: children.find(child => keyOf(child) === 'menu-tip') ?? null,
35 }
36}
37
38// The one-line tip above the pill row, filled while the pointer is on an entry: its "Name:" in bold accent, the rest muted.
39export const tipLine = ({ Box, Text }: Elements, text: string) => {
40 const at = text.indexOf(': ')
41 return (
42 <Box key="menu-tip" paddingLeft={2} flexDirection="row">
43 {at < 0 ? null : <Text color={THEME.accent} bold>{text.slice(0, at + 1)}</Text>}
44 <Text color={THEME.muted}>
45 {(at < 0 ? text : text.slice(at + 1)).split(style.marks.sep).flatMap((part, index) => (index === 0 ? [part] : [<Text key={`sep-${index}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
46 </Text>
47 </Box>
48 )
49}
50
51// One pill with its own caps where the font has them; `on` is what it sits on, `hover` lights the caps with a Button scoped `key`.
52export function pill({ Box, Text }: Elements, nerd: boolean, bg: string, body: unknown, key = 'menu-pill', on?: string, hover?: string) {
53 const under = on ? { backgroundColor: on } : {}
54 const lit = hover ? { hover: { scope: key, color: hover } } : {}
55 return (
56 <Box key={key} flexDirection="row">
57 {nerd ? <Text key={`${key}-cap-left`} color={bg} {...under} {...lit}>{CAPS.left}</Text> : null}
58 <Box key={`${key}-body`} flexDirection="row" backgroundColor={bg}>
59 {body}
60 </Box>
61 {nerd ? <Text key={`${key}-cap-right`} color={bg} {...under} {...lit}>{CAPS.right}</Text> : null}
62 </Box>
63 )
64}
65
66// mod-menu's `labels`: `always` icon and label, `never` the icon alone, `auto` the icon alone while the row overflows (Nerd only; safe keeps labels).
67export type LabelsMode = 'auto' | 'always' | 'never'
68
69// The value of `{ plugin: 'mod-menu', key: 'labels' }` as a mode; a missing or unknown value is `auto`, so a mod works without mod-menu.
70export const labelsOf = (value: unknown): LabelsMode => (value === 'always' || value === 'never' ? value : 'auto')
71
72type ClientNode = { type: 'Client'; props: { props: Record<string, unknown> } }
73const isClient = (node: unknown): node is ClientNode => (node as { type?: string } | null)?.type === 'Client'
74
75// The cells a drawn row takes with every label shown: a Client that may go compact says its own `cells`, or is an entry.tsx one.
76const cellsOf = (node: unknown): number => {
77 if (Array.isArray(node)) return node.reduce((sum: number, each) => sum + cellsOf(each), 0)
78 if (typeof node === 'string') return [...node].length
79 if (!node || typeof node !== 'object') return 0
80 if (isClient(node)) {
81 const { props } = node.props
82 return typeof props.cells === 'number' ? props.cells : entryCells({ ...(props as EntryProps), compact: false })
83 }
84 // A `menu-flash` word comes and goes with the pointer; counting it would flip the row under the pointer.
85 const kids = childrenOf(node).filter(each => each && !keyOf(each)?.startsWith('menu-flash'))
86 return kids.reduce((sum: number, each) => sum + cellsOf(each), Number((node as Node)?.props?.columnGap ?? 0) * Math.max(0, kids.length - 1))
87}
88
89const compacted = (node: unknown): unknown => {
90 if (Array.isArray(node)) return node.map(compacted)
91 if (!node || typeof node !== 'object') return node
92 if (isClient(node)) return node.props.props.compact === false ? { ...node, props: { ...node.props, props: { ...node.props.props, compact: true } } } : node
93 const kids = (node as Node)?.children
94 return kids ? { ...node, children: kids.map(compacted) } : node
95}
96
97// A panel's entry in the menu group, keyed `menu-pad-<id>` for the row's order; the open one is a pill of its own.
98export function menuEntry({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, id: string, { labels, ...props }: Omit<EntryProps, 'id' | 'bg' | 'tight'> & { labels?: LabelsMode }) {
99 // The open panel reads as selected, like an On switch: ok text on ok washed into the group fill.
100 const bg = props.isOpen ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary
101 const glyph = (style.glyphs.nerd.icon as Record<string, string>)[`menu-${id}`]
102 const icon = nerd && !props.lead && glyph ? decodeAll(glyph) : undefined
103 // The icon sits tight against the cap, so the label that follows brings its own gap.
104 const shown = icon ? { lead: icon, label: labels === 'never' ? '' : ` ${props.label}`, ...(labels === 'auto' ? { compact: false } : {}) } : {}
105 // On Nerd the caps take the label's padding cells, so an entry is as wide as before the caps.
106 const entry = <Client key={`menu-entry-${id}`} module="./entry.tsx" height={1} props={{ id, bg, ...props, ...(props.isOpen ? { color: THEME.ok } : {}), ...shown, nerd, tight: nerd } satisfies EntryProps} />
107 return (
108 <Box key={`menu-pad-${id}`} flexDirection="row">
109 {/* A closed entry gets caps in the group's own color, so opening a panel never shifts its neighbours. */}
110 {pill({ Box, Text }, nerd, bg, entry, props.isOpen ? `menu-open-${id}` : `menu-closed-${id}`, PILL.primary)}
111 </Box>
112 )
113}
114
115// The band above the prompt: what else it holds (Claude Code's own effort line), then the pill row, so the pills sit on the input.
116export function pillRow(elements: Elements & { Client?: (props: any) => RenderElement }, nerd: boolean, group: unknown[], row: unknown[], rest: unknown[], tip: unknown = null, columns?: number) {
117 const { Box, Client, Text } = elements
118 // Load order changes with an install or a toggle, so entries sort by a fixed rank.
119 const rank = (entry: unknown) => {
120 const at = ENTRY_ORDER.indexOf(String(keyOf(entry) ?? '').replace(/^menu-pad-/, ''))
121 return at < 0 ? ENTRY_ORDER.length : at
122 }
123 // Like a choice bar: the entries side by side on one pill, the open panel's lifted into its own, a divider Client between neighbours.
124 const sorted = [...group].sort((a, b) => rank(a) - rank(b))
125 const entries = Client
126 ? sorted.flatMap((entry, at) => (at === 0 ? [entry] : [<Client key={`menu-group-sep-${at}`} module="./entry.tsx" height={1} props={{ id: `sep-${at}`, label: '', isOpen: false, bg: PILL.primary, rail: true } satisfies EntryProps} />, entry]))
127 : sorted
128 const pills = (
129 <Box key="menu-row" flexDirection="row" columnGap={1}>
130 {group.length > 0 ? pill(elements, nerd, PILL.primary, entries, 'menu-group') : null}
131 {row}
132 </Box>
133 )
134 // Measured with every label shown, so a row cut to icons never grows back into a row that does not fit.
135 const fits = columns === undefined || cellsOf(pills) <= columns
136 return (
137 <Box key="menu-rows" flexDirection="column">
138 {rest}
139 {/* Above the row, kept even when empty so nothing above jumps when a tip appears. */}
140 {tip ?? tipLine(elements, ' ')}
141 {fits ? pills : compacted(pills)}
142 </Box>
143 )
144}
145
146// One chip per view of a panel (its list, its settings), standing in for the title: id, label, icon glyph, an optional count and an optional hover tip.
147export type Tab = { id: string; label: string; icon: string; isOpen: boolean; count?: number; tip?: Tip }
148
149// A panel's title line: the open view on surface3 and bold, the others on surface2 and muted; a click swaps the view.
150export function tabRow({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, tabs: Tab[], still: boolean) {
151 return (
152 <Box key="tab-row" flexDirection="row" columnGap={1}>
153 {tabs.map(tab => {
154 const bg = tab.isOpen ? THEME.surface3 : THEME.surface2
155 // Client props refuse an undefined value, so a tab without a count or tip leaves the key out.
156 const countColor = tab.isOpen ? THEME.accent : style.colors.pills.readout
157 const badge = tab.count === undefined ? {} : { badge: { text: String(tab.count), color: countColor, bg: mix(countColor, bg, WASH) } }
158 const tip = tab.tip ? { tip: tab.tip } : {}
159 const entry = (
160 <Client key={tab.id} module="./entry.tsx" height={1} props={{ id: tab.id, label: tab.label, isOpen: tab.isOpen, bg, idle: THEME.muted, still, subLead: tab.icon, subLeadColor: THEME.accent, nerd, ...(tab.isOpen ? { color: THEME.secondary } : {}), ...badge, ...tip } satisfies EntryProps} />
161 )
162 return pill({ Box, Text }, nerd, bg, entry, `${tab.id}-pill`)
163 })}
164 </Box>
165 )
166}
167hooks/toasts.tsx 87 lines1// hooks/toasts.tsx, by v-amorim
2// Shared toast cards; every mod that toasts carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import type { Tip } from './entry'
6import style from './style'
7import { CAPS, MARK, mix, THEME } from './theme'
8
9type Elements = { Box: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
10
11// The slice of a mod's Look the cards read, typed here because mod-menu has no look.tsx.
12type ToastLook = { nerd: boolean; glyph: { close: string } }
13
14export const TOAST_MS = 4000
15
16const DISMISS_TIP = { label: 'Dismiss', text: 'hides this message' }
17const MAX_TOASTS = 3
18
19// A mod's own toasts, newest last. A toast reaches only the hooks above its caller, so each mod catches its own.
20export class ToastList {
21 items: { id: number; text: string }[] = []
22 // The owning mod's animations setting is off, so the dismiss × does not fade.
23 isStill = false
24 private lastId = 0
25
26 // `prefix` keeps the dismiss Client keys apart from other mods' cards in the band.
27 constructor(readonly prefix: string) {}
28
29 add(text: string) {
30 const id = (this.lastId += 1)
31 this.items = [...this.items, { id, text }].slice(-MAX_TOASTS)
32 return id
33 }
34
35 drop(id: number) {
36 this.items = this.items.filter(toast => toast.id !== id)
37 }
38
39 // The toast a dismiss click names, or undefined when the click was not on one of these cards.
40 idOf(entryId: string) {
41 const head = `toast-${this.prefix}-`
42 return entryId.startsWith(head) ? Number(entryId.slice(head.length)) : undefined
43 }
44}
45
46// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[×]`).
47const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
48
49// Callers write `✓ Saved` or `! Failed`; the card draws the mark.
50function toneOf(text: string) {
51 for (const [mark, color, bold] of [[MARK.ok, THEME.good, false], [MARK.warn, THEME.alert, true]] as const) {
52 if (text.startsWith(mark)) return { mark, color, bold, text: text.slice(mark.length).trimStart() }
53 }
54 return { mark: MARK.bullet, color: THEME.accent, bold: false, text }
55}
56
57// The web toast as a band card: tinted icon square, "Title: detail" split bold and muted, an error edged in alert; closeTip is the × tip.
58export const toastCards = ({ Box, Client, Text }: Elements, list: ToastList, look?: ToastLook, closeTip: Pick<Tip, 'label' | 'text'> = DISMISS_TIP) =>
59 list.items.map(toast => {
60 const id = `toast-${list.prefix}-${toast.id}`
61 const tone = toneOf(toast.text)
62 const nerd = look?.nerd === true
63 const close = bare(look?.glyph.close ?? style.glyphs.safe.close)
64 const fill = mix(THEME.surface1, tone.color, 0.06)
65 const split = tone.text.indexOf(': ')
66 const [title, detail] = split > 0 ? [tone.text.slice(0, split), tone.text.slice(split + 2)] : [tone.text, '']
67 const edge = tone.color === THEME.alert ? mix(fill, THEME.alert, 0.33) : THEME.border
68 return (
69 <Box key={id} flexDirection="row" columnGap={1} paddingX={1} borderStyle="round" borderColor={edge} backgroundColor={fill}>
70 <Box key={`${id}-mark`} flexShrink={0}>
71 <Text color={tone.color} backgroundColor={mix(fill, tone.color, 0.15)} bold={tone.bold}>{` ${tone.mark} `}</Text>
72 </Box>
73 <Box key={`${id}-text`} flexGrow={1} flexShrink={1}>
74 <Text wrap="wrap">
75 <Text color={THEME.onSurface} bold>{title}</Text>
76 {detail ? <Text color={THEME.muted}>{` ${detail}`}</Text> : null}
77 </Text>
78 </Box>
79 <Box key={`${id}-close`} flexDirection="row" flexShrink={0}>
80 {nerd ? <Text key={`${id}-close-left`} color={THEME.surface3} backgroundColor={fill}>{CAPS.left}</Text> : null}
81 <Client key={id} module="./entry.tsx" height={1} props={{ id, label: close, isOpen: false, bg: THEME.surface3, still: list.isStill, tip: { glyph: close, ...closeTip } }} />
82 {nerd ? <Text key={`${id}-close-right`} color={THEME.surface3} backgroundColor={fill}>{CAPS.right}</Text> : null}
83 </Box>
84 </Box>
85 )
86 })
87hooks/spend.ts 64 lines1// mod-next-steps / hooks/spend.ts, by v-amorim
2// Adds each side-model call's usage to <configDir>/mods-data/mod-next-steps/spend.json, one bucket per UTC day and model. Never throws.
3import type { EngineInterface, ModelUsage } from 'claude-code'
4
5const MOD = 'mod-next-steps'
6const KEEP_DAYS = 30
7const DAY_MS = 86_400_000
8
9type Counts = { calls: number; input: number; output: number; cacheRead: number; cacheWrite: number }
10type SpendFile = { days: Record<string, Record<string, Counts>> }
11
12const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0)
13const dayKey = (ms: number) => new Date(ms).toISOString().slice(0, 10)
14
15// Reads what is there, keeps only well-formed buckets from the last KEEP_DAYS days, and adds one call.
16export function withCall(raw: unknown, model: string, usage: Partial<ModelUsage> | undefined, now: number): SpendFile {
17 const oldest = dayKey(now - (KEEP_DAYS - 1) * DAY_MS)
18 const days: SpendFile['days'] = {}
19 const source = raw && typeof raw === 'object' ? (raw as { days?: unknown }).days : null
20 if (source && typeof source === 'object') {
21 for (const [day, models] of Object.entries(source as Record<string, unknown>)) {
22 if (day < oldest || !/^\d{4}-\d{2}-\d{2}$/.test(day) || !models || typeof models !== 'object') continue
23 days[day] = {}
24 for (const [name, c] of Object.entries(models as Record<string, Record<string, unknown>>)) {
25 if (c && typeof c === 'object') days[day][name] = { calls: count(c.calls), input: count(c.input), output: count(c.output), cacheRead: count(c.cacheRead), cacheWrite: count(c.cacheWrite) }
26 }
27 }
28 }
29 const today = (days[dayKey(now)] ??= {})
30 const was = (today[model] ??= { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
31 was.calls += 1
32 was.input += count(usage?.input_tokens)
33 was.output += count(usage?.output_tokens)
34 was.cacheRead += count(usage?.cache_read_input_tokens)
35 was.cacheWrite += count(usage?.cache_creation_input_tokens)
36 return { days }
37}
38
39async function configDir($: EngineInterface) {
40 const custom = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? '').trim()
41 const dir = custom || `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''}/.claude`
42 return dir.replace(/\\/g, '/').replace(/\/+$/, '')
43}
44
45// One queue per process, so two calls ending together do not overwrite each other's add.
46let queue: Promise<unknown> = Promise.resolve()
47
48// Records a finished call under the model that was asked for; a failed or missing read starts a fresh file, a failed write is dropped.
49export function recordSpend($: EngineInterface, model: string, result: { usage?: Partial<ModelUsage> } | null | undefined) {
50 if (!result?.usage) return
51 const { usage } = result
52 if (!count(usage.input_tokens) && !count(usage.output_tokens) && !count(usage.cache_read_input_tokens) && !count(usage.cache_creation_input_tokens)) return
53 queue = queue.then(async () => {
54 try {
55 const path = `${await configDir($)}/mods-data/${MOD}/spend.json`
56 const raw = await $.fs.read(path).then(file => JSON.parse(String(file)) as unknown).catch(() => null)
57 const now = await $.clock.now()
58 await $.fs.write(path, JSON.stringify(withCall(raw, model || 'unknown', usage, now)))
59 } catch {
60 // Spend is a courtesy: the mod works the same without it.
61 }
62 })
63}
64hooks/text.ts 25 lines1// mod-next-steps / 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 "settingsWaiting": "Settings panel waiting: {reason}",
5 "suggestionsCleared": "Suggestions cleared",
6 "commandDescription": "Next-prompt suggestions above the input; `settings` opens its settings panel, `clear` drops the current ones",
7 "sideUnavailable": "Side check unavailable",
8 "sideUnavailableWhy": "Side check unavailable: {reason}",
9 "thinking": "Thinking of next steps",
10 "cardTitle": "Next steps",
11 "cardHint": "click one to send it, or press its number",
12 "paneHeading": "Next steps",
13 "paneHeadingSettings": "Settings",
14 "statusHint": "Click a setting to change it",
15 "statusReload": "The mod reloads",
16 "settingSaved": "{label} saved",
17 "tipSuggestionLabel": "Suggestion {n}",
18 "tipSuggestion": "puts this suggestion into your prompt",
19 "tipDismissLabel": "Clear",
20 "tipDismiss": "clears the suggestions",
21 "tipTabSettings": "shows this mod's settings",
22 "tipToast": {"label":"Dismiss","text":"hides this message"},
23 "settingTips": {"enabled":"lists next prompts after a turn"}
24}
25hooks/style.ts 280 lines1// hooks/style.ts, by v-amorim
2// Shared style; every mod carries the same copy. Change values freely, keep the keys. {U+XXXX} is a Nerd Font code point.
3export default {
4 "colors": {
5 "theme": {
6 "bg": "#0D0E17",
7 "surface1": "#141726",
8 "surface2": "#1C2033",
9 "surface3": "#252A42",
10 "lifted": "#2F3654",
11 "trackOff": "#4B5270",
12 "border": "#242839",
13 "onSurface": "#EEEEFA",
14 "muted": "#9BA3C4",
15 "accent": "#7386D0",
16 "secondary": "#B9C6F5",
17 "good": "#49EF95",
18 "ok": "#A2B0EA",
19 "alert": "#CA5F71",
20 "warn": "#FFCB6B",
21 "folder": "#86E1FC",
22 "pill": "#B9C6F5",
23 "addBg": "#1D3B38",
24 "delBg": "#3D2737"
25 },
26 "syntax": {
27 "command": "#7386D0",
28 "option": "#FFAFAF",
29 "text": "#A5CFFF",
30 "variable": "#79C0FF",
31 "number": "#B0C8FF",
32 "operator": "#CDCFE7"
33 },
34 "pills": {
35 "primary": "#252A42",
36 "secondary": "#1C2033",
37 "separator": "#3B446A",
38 "readout": "#7E86A4"
39 },
40 "shimmer": [
41 "#A2B0EA",
42 "#949FCE",
43 "#8993B9"
44 ],
45 "usage": [
46 "#B9C6F5",
47 "#ACB9E6",
48 "#A5A1E9",
49 "#CF95CA",
50 "#CA5FA6",
51 "#CA5F71"
52 ],
53 "statusline": {
54 "pillBorder": "#3A3D4A",
55 "menuBackground": "#1C1D24",
56 "divergence": "#B86CB3",
57 "behind": "#8A9BE0"
58 },
59 "models": {
60 "opus": "#C3A6FF",
61 "sonnet": "#79C0FF",
62 "haiku": "#7CD591",
63 "inherit": "#9BA3C4"
64 },
65 "kinds": {
66 "thinking": { "hue": "#C3A6FF", "wash": "#494267" },
67 "read": { "hue": "#B9C6F5", "wash": "#464C64" },
68 "search": { "hue": "#A5CFFF", "wash": "#404E67" },
69 "edit": { "hue": "#B9C6F5", "wash": "#4C5167" },
70 "shell": { "hue": "#FFCB6B", "wash": "#5B4D3B" },
71 "web": { "hue": "#79C0FF", "wash": "#324A67" },
72 "agent": { "hue": "#F99BD1", "wash": "#593F59" },
73 "tool": { "hue": "#9BA3C4", "wash": "#3D4155" }
74 }
75 },
76 "tags": {
77 "router": {
78 "color": "#F99BD1",
79 "icon": "◆"
80 },
81 "debug": {
82 "color": "#FF9C8B",
83 "icon": "‼"
84 },
85 "interface": {
86 "color": "#EEB154",
87 "icon": "▪"
88 },
89 "writing": {
90 "color": "#C3C55A",
91 "icon": "≡"
92 },
93 "utility": {
94 "color": "#7CD591",
95 "icon": "☼"
96 },
97 "start": {
98 "color": "#2AD7D7",
99 "icon": "»"
100 },
101 "mods": {
102 "color": "#5DCBFF",
103 "icon": "+"
104 },
105 "review": {
106 "color": "#D1A8FF",
107 "icon": "∴"
108 },
109 "personal": {
110 "color": "#9BA3C4",
111 "icon": "●"
112 }
113 },
114 "levels": {
115 "low": { "glyph": "○", "color": "#ACB9E6" },
116 "medium": { "glyph": "◐", "color": "#A5A1E9" },
117 "high": { "glyph": "●", "color": "#CF95CA" },
118 "xhigh": { "glyph": "◉", "color": "#CA5FA6" },
119 "max": { "glyph": "◈", "color": "#CA5F71" }
120 },
121 "statuslineMarks": {
122 "branch": "⑂",
123 "dirty": "●",
124 "diverged": "⇅",
125 "ahead": "↑",
126 "behind": "↓",
127 "pathSep": "›",
128 "menu": "⋯",
129 "close": "×"
130 },
131 "text": {
132 "settings": "Settings",
133 "actions": "Actions",
134 "noSettings": "No settings.",
135 "on": "On",
136 "off": "Off",
137 "locked": "locked",
138 "editInConfig": "edit it in /config",
139 "idleOnly": "available once Claude is idle",
140 "runsCommand": "runs /{command}"
141 },
142 "marks": {
143 "stage": "▸",
144 "cursor": "❯",
145 "bullet": "•",
146 "ok": "✓",
147 "warn": "!",
148 "sep": "·",
149 "stop": "○",
150 "stopOpen": "●",
151 "rail": "│",
152 "rule": "─",
153 "track": "━━",
154 "knob": "●",
155 "spinner": "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏",
156 "open": "↗",
157 "strip": "▎"
158 },
159 "caps": {
160 "left": "{U+E0B6}",
161 "right": "{U+E0B4}"
162 },
163 "glyphs": {
164 "safe": {
165 "on": "[◉]",
166 "off": "[○]",
167 "go": "[↵]",
168 "reveal": "⊞",
169 "cycle": "[↻]",
170 "down": "[−]",
171 "up": "[+]",
172 "locked": "[■]",
173 "close": "[×]",
174 "warn": "!",
175 "ok": "✓",
176 "expanded": "▾",
177 "collapsed": "▸",
178 "more": "▾",
179 "less": "▴",
180 "back": "<",
181 "title": "▸",
182 "gear": "⊛",
183 "copy": "⧉",
184 "icon": { "cost": "$", "arrow": "→" },
185 "mark": {
186 "thinking": "∴",
187 "read": "≡",
188 "search": "?",
189 "edit": "±",
190 "shell": "$",
191 "web": "@",
192 "agent": "◆",
193 "tool": "•"
194 }
195 },
196 "nerd": {
197 "on": " {U+F205} ",
198 "off": " {U+F204} ",
199 "go": " {U+F054} ",
200 "reveal": "{U+EAF7}",
201 "cycle": " {U+F021} ",
202 "down": " {U+F068} ",
203 "up": " {U+F067} ",
204 "locked": " {U+F023} ",
205 "close": " {U+F00D} ",
206 "warn": "{U+EA6C}",
207 "ok": "{U+EAB2}",
208 "expanded": "{U+EAB4}",
209 "collapsed": "{U+EAB6}",
210 "more": "{U+F0D7}",
211 "less": "{U+F0D8}",
212 "back": "{U+F053}",
213 "title": "{U+F054}",
214 "gear": "{U+F0493}",
215 "copy": "{U+F018F}",
216 "icon": {
217 "settings": "{U+F1DE}",
218 "actions": "{U+F0E7}",
219 "wrapup": "{U+F187}",
220 "features": "{U+F005}",
221 "mod": "{U+F12E}",
222 "installed": "{U+F487}",
223 "arrow": "{U+F061}",
224 "not-installed": "{U+F019}",
225 "icons": "{U+F031}",
226 "updates": "{U+F0AA}",
227 "welcome": "{U+F135}",
228 "apply": "{U+F00C}",
229 "cost": "{U+F155}",
230 "skill": "{U+F0D0}",
231 "prompt": "{U+F075}",
232 "fold": "{U+F066}",
233 "files": "{U+EB86}",
234 "replay": "{U+EA82}",
235 "folder": "{U+EA83}",
236 "folder-open": "{U+EAF7}",
237 "file": "{U+EA7B}",
238 "ext-ts": "{U+E628}",
239 "ext-react": "{U+E625}",
240 "ext-js": "{U+E781}",
241 "ext-json": "{U+E60B}",
242 "ext-md": "{U+E609}",
243 "ext-py": "{U+E73C}",
244 "ext-config": "{U+E615}",
245 "ext-sh": "{U+F489}",
246 "Edit": "{U+F040}",
247 "MultiEdit": "{U+F040}",
248 "NotebookEdit": "{U+F040}",
249 "Write": "{U+F15B}",
250 "Read": "{U+F02D}",
251 "Bash": "{U+F120}",
252 "Grep": "{U+F002}",
253 "Glob": "{U+F002}",
254 "Agent": "{U+EBBC}",
255 "Task": "{U+EBBC}",
256 "Skill": "{U+F0D0}",
257 "WebFetch": "{U+F0AC}",
258 "WebSearch": "{U+F0AC}",
259 "TodoWrite": "{U+F0AE}",
260 "tool": "{U+F0AD}",
261 "menu-skills": "{U+F0D0}",
262 "menu-prompts": "{U+F075}",
263 "menu-filetree": "{U+EB86}",
264 "menu-progress": "{U+EBBC}",
265 "menu-headsup": "{U+EA6C}"
266 },
267 "mark": {
268 "thinking": "{U+F09D1}",
269 "read": "{U+F02D}",
270 "search": "{U+F002}",
271 "edit": "{U+F040}",
272 "shell": "{U+F120}",
273 "web": "{U+F0AC}",
274 "agent": "{U+EBBC}",
275 "tool": "{U+F0AD}"
276 }
277 }
278 }
279}
280types/index.d.ts 10 lines1// mod-next-steps / types/index.d.ts, by v-amorim
2// Declares the mod's plugin state type and a read-only flag from another mod.
3declare module 'claude-code' {
4 interface PluginState {
5 'mod-next-steps': { pane: boolean }
6 // Read only: true while mod-blast-radius holds a command on its card.
7 'mod-blast-radius': { holding: boolean }
8 }
9}
10