SLOPSHOPPER

mod-next-steps

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

newpanebandcommandtoastprompt
★ 2v1.0.5MITupdated 2026-10-26v-amorim/moonlight-claude-mods/mod-next-steps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-next-steps
│ ┃ Next steps ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./entry.tsx │ ┃ Click a setting to change it · The mod re… ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No settings. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /mod-next-steps │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Next steps
▣ client module ./entry.tsx Click a setting to change it · The mod reloads No settings. ────────────────────────────────────────────────────────
README

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

mod-next-steps

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

Suggests up to three next prompts after each turn.

Install

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

How to open

Automatic.

See it

Three suggestions
![][img-next-steps]
Picking one by its digit
https://github.com/user-attachments/assets/d89e12c2-c107-421e-ba16-653f2ae55deb

Features

  • Suggestions: After a finished turn, up to three one-line next prompts appear above the input, muted until the pointer is over one.
  • Click to fill: A click puts the suggestion in the input for you to edit; nothing is sent.
  • Stays out of the way: They clear when you send a prompt or a new turn starts, and a failed or interrupted turn shows nothing.

Costs

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.

Settings

Change them with /mod-next-steps settings or the gear pill.

SettingDefaultWhat it does
AnimationsonEntries shimmer while they wait. Hover colors also fade. Off, everything draws still
Suggest next stepsoffLists next prompts after a turn. The model writes them after each finished turn. Off, no request is made
Suggestions3How many suggestions to show, 1 to 3
Iconsautoauto picks Nerd Font or safe glyphs. It uses Nerd Font icons when the terminal's font is a Nerd Font (read from Windows Terminal, iTerm2, VS Code, Ghostty, kitty, WezTerm, Alacritty, GNOME Terminal or Konsole) and safe Unicode glyphs elsewhere. nerd and safe force one

License

Made by [Vinicius Amorim][doc-author], [MIT license][doc-license].

[doc-author]: https://github.com/v-amorim [doc-icons]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#icons-and-nerd-fonts [doc-install]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md#install [doc-license]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/LICENSE [doc-root]: https://github.com/v-amorim/moonlight-claude-mods/blob/main/README.md

[img-next-steps]: https://github.com/user-attachments/assets/82b8aa72-46c1-4e78-9c39-94423d61d977

Source 11 files
hooks/register.tsx 432 lines
1// 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}
432
hooks/look.tsx 279 lines
1// hooks/look.tsx, by v-amorim
2// Shared look; every mod carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import style from './style'
6import type { Tip } from './entry'
7import { CAPS, decodeAll, MARK, mix, THEME, WASH } from './theme'
8
9type Glyphs = Omit<typeof style.glyphs.nerd, 'icon' | 'mark'> & { icon: Record<string, string>; mark: Record<string, string> }
10
11// Safe mode: common Unicode that every modern terminal font draws. Headings and rows carry no icon.
12const SAFE: Glyphs = decodeAll(style.glyphs.safe)
13
14// Nerd mode: Font Awesome, Material and VS Code glyphs from any Nerd Font, controls padded to the same three cells.
15const NERD: Glyphs = decodeAll(style.glyphs.nerd)
16
17export type Look = { nerd: boolean; glyph: typeof SAFE }
18
19export const lookFor = (nerd: boolean): Look => ({ nerd, glyph: nerd ? NERD : SAFE })
20
21// The icons setting every drawing mod declares in its userConfig.
22export type IconMode = 'auto' | 'nerd' | 'safe'
23
24export const terminalSettings = (localAppData: string) => [
25  `${localAppData}/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json`,
26  `${localAppData}/Microsoft/Windows Terminal/settings.json`,
27]
28
29// Matches "Fira Code Nerd Font Mono" and the patched suffixes NF, NFM and NFP, as in "FiraCodeNFM-Reg".
30export const isNerdName = (face: string) => /nerd|\bNFM?\b/i.test(face) || /NF[MP]?(?![A-Za-z])/.test(face)
31
32export const isNerdFace = (settingsText: string) => [...settingsText.matchAll(/"face"\s*:\s*"([^"]*)"/g)].some(match => isNerdName(match[1]!))
33
34// The variables that name the terminal: WT_SESSION from Windows Terminal, TERM_PROGRAM from programs such as mintty and iTerm2.
35export type TerminalEnv = Partial<
36  Record<'WT_SESSION' | 'TERM_PROGRAM' | 'ITERM_PROFILE' | 'KITTY_WINDOW_ID' | 'ALACRITTY_WINDOW_ID' | 'GNOME_TERMINAL_SCREEN' | 'KONSOLE_VERSION', string | undefined>
37>
38
39// What detection reads: files by path, and the stdout of a command that exits 0 (it throws otherwise).
40export type Host = { localAppData: string; home: string; read: (path: string) => Promise<string>; run: (argv: string[]) => Promise<string> }
41
42// mintty inherits WT_SESSION from a Windows Terminal tab, so only an unset or Windows Terminal TERM_PROGRAM counts.
43export const isWindowsTerminal = ({ WT_SESSION, TERM_PROGRAM }: TerminalEnv) =>
44  Boolean(WT_SESSION) && (!TERM_PROGRAM || /windows\s*terminal/i.test(TERM_PROGRAM))
45
46// Terminals whose font sits in a text config: the first file that names a font wins, and within a file the first pattern.
47const CONFIG_TERMINALS: { is: (env: TerminalEnv) => boolean; files: (host: Host) => string[]; faces: RegExp[] }[] = [
48  {
49    is: env => env.TERM_PROGRAM === 'vscode',
50    files: ({ home, localAppData }) => [
51      `${home}/Library/Application Support/Code/User/settings.json`,
52      `${home}/.config/Code/User/settings.json`,
53      `${localAppData.replace(/\/Local$/, '/Roaming')}/Code/User/settings.json`,
54    ],
55    faces: [/"terminal\.integrated\.fontFamily"\s*:\s*"([^"]*)"/, /"editor\.fontFamily"\s*:\s*"([^"]*)"/],
56  },
57  {
58    is: env => env.TERM_PROGRAM === 'ghostty',
59    files: ({ home }) => [`${home}/.config/ghostty/config`, `${home}/Library/Application Support/com.mitchellh.ghostty/config`],
60    faces: [/^\s*font-family\s*=\s*"?([^"\n#]+)/m],
61  },
62  {
63    is: env => Boolean(env.KITTY_WINDOW_ID),
64    files: ({ home }) => [`${home}/.config/kitty/kitty.conf`],
65    faces: [/^\s*font_family\s+(.+)$/m],
66  },
67  {
68    is: env => env.TERM_PROGRAM === 'WezTerm',
69    files: ({ home }) => [`${home}/.wezterm.lua`, `${home}/.config/wezterm/wezterm.lua`],
70    faces: [/wezterm\.font(?:_with_fallback)?\(\s*\{?\s*["']([^"']+)["']/],
71  },
72  {
73    is: env => Boolean(env.ALACRITTY_WINDOW_ID),
74    files: ({ home }) => [`${home}/.config/alacritty/alacritty.toml`],
75    faces: [/\[font\.normal\][^[]*?family\s*=\s*"([^"]+)"/],
76  },
77]
78
79// iTerm2 keeps its profiles in a plist; plutil prints the part asked for. The profile is the one this window runs, else the default.
80async function itermFace(env: TerminalEnv, host: Host) {
81  const plist = `${host.home}/Library/Preferences/com.googlecode.iterm2.plist`
82  const profiles: { Name?: string; Guid?: string; 'Normal Font'?: string }[] = JSON.parse(await host.run(['plutil', '-extract', 'New Bookmarks', 'json', '-o', '-', plist]))
83  const named = profiles.find(profile => profile.Name === env.ITERM_PROFILE)
84  if (named) return named['Normal Font']
85  const guid = (await host.run(['plutil', '-extract', 'Default Bookmark Guid', 'raw', '-o', '-', plist])).trim()
86  return profiles.find(profile => profile.Guid === guid)?.['Normal Font']
87}
88
89// GNOME Terminal keeps the default profile's font in dconf, and ignores it while the profile uses the system font.
90async function gnomeFace(host: Host) {
91  const unquote = (text: string) => text.trim().replace(/^'|'$/g, '')
92  const schema = `org.gnome.Terminal.Legacy.Profile:/org/gnome/terminal/legacy/profiles:/:${unquote(await host.run(['gsettings', 'get', 'org.gnome.Terminal.ProfilesList', 'default']))}/`
93  if (unquote(await host.run(['gsettings', 'get', schema, 'use-system-font'])) === 'true') return undefined
94  return unquote(await host.run(['gsettings', 'get', schema, 'font']))
95}
96
97// Konsole names its profile in konsolerc, and the profile file holds `Font=Name,size,...`.
98async function konsoleFace(host: Host) {
99  const profile = /^DefaultProfile=(.+)$/m.exec(await host.read(`${host.home}/.config/konsolerc`))?.[1]?.trim()
100  return profile ? /^Font=([^,\n]+)/m.exec(await host.read(`${host.home}/.local/share/konsole/${profile}`))?.[1] : undefined
101}
102
103async function configFace(terminal: (typeof CONFIG_TERMINALS)[number], host: Host) {
104  for (const path of terminal.files(host)) {
105    const text = await host.read(path).catch(() => '')
106    for (const pattern of terminal.faces) {
107      const face = pattern.exec(text)?.[1]
108      if (face) return face
109    }
110  }
111  return undefined
112}
113
114function terminalFace(env: TerminalEnv, host: Host) {
115  if (env.TERM_PROGRAM === 'iTerm.app') return itermFace(env, host)
116  if (env.GNOME_TERMINAL_SCREEN) return gnomeFace(host)
117  if (env.KONSOLE_VERSION) return konsoleFace(host)
118  const terminal = CONFIG_TERMINALS.find(candidate => candidate.is(env))
119  return terminal ? configFace(terminal, host) : Promise.resolve(undefined)
120}
121
122// Every mod looks up the font on start at once, so a lookup can fail by chance; a third failure throws and the caller keeps its saved answer.
123async function retried<T>(attempt: () => Promise<T>, tries = 3): Promise<T> {
124  for (let left = tries - 1; ; left--) {
125    try {
126      return await attempt()
127    } catch (error) {
128      if (left === 0) throw error
129    }
130  }
131}
132
133// Nerd glyphs only when the terminal this window runs in names a Nerd font; an unknown terminal stays safe, and a lookup that keeps failing throws.
134export async function detectNerd(env: TerminalEnv, host: Host) {
135  if (isWindowsTerminal(env)) {
136    for (const path of terminalSettings(host.localAppData)) {
137      if (isNerdFace(await host.read(path).catch(() => ''))) return true
138    }
139    return false
140  }
141  const face = await retried(() => terminalFace(env, host))
142  return face !== undefined && isNerdName(face)
143}
144
145// `text` led by its icon in Nerd mode; the text alone in safe mode or when the name has no icon.
146export const withIcon = (look: Look, name: string, text: string) => (look.glyph.icon[name] ? `${look.glyph.icon[name]} ${text}` : text)
147
148// A status note with its leading mark (MARK.warn or MARK.ok from theme.ts) drawn as this look's glyph.
149export function withMark(look: Look, note: string) {
150  if (note.startsWith('! ')) return `${look.glyph.warn} ${note.slice(2)}`
151  if (note.startsWith('✓ ')) return `${look.glyph.ok} ${note.slice(2)}`
152  return note
153}
154
155type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
156
157// A pane's title line: the name's icon in Nerd mode, the safe mark otherwise.
158export const paneTitle = ({ Text }: Elements, look: Look, icon: string, text: string) => (
159  <Text key="pane-title" color={THEME.accent} bold>{`${look.glyph.icon[icon] ?? look.glyph.title} ${text}`}</Text>
160)
161
162// One line of text in `color` with every separator dot in the separator colour, since a dot in the text's colour reads as part of the words.
163export function sepText({ Text }: Pick<Elements, 'Text'>, text: string, color: string, key: string, props: Record<string, unknown> = {}) {
164  return (
165    <Text key={key} {...props}>
166      {text.split(MARK.sep).flatMap((part, at) => [
167        ...(at > 0 ? [<Text key={`${key}-sep-${at}`} color={style.colors.pills.separator}>{MARK.sep}</Text>] : []),
168        <Text key={`${key}-part-${at}`} color={color}>{part}</Text>,
169      ])}
170    </Text>
171  )
172}
173
174// The footer rows every pane reserves at its bottom: a rule, then the hovered control's glyph, label and what it does.
175export const FOOTER_ROWS = 2
176
177// A Button cannot post a tip, so its tip is drawn hidden and shown by the hover group the Button's pill shares.
178export type Reveal = { scope: string; tip: Tip }
179
180const tipRow = ({ Box, Text }: Elements, key: string, tip: Tip | null, columns: number, extra: Record<string, unknown> = {}) => (
181  <Box key={key} flexDirection="row" paddingX={1} width={columns} height={1} backgroundColor={THEME.bg} {...extra}>
182    {tip ? <Text color={THEME.accent} backgroundColor={THEME.bg}>{tip.glyph}</Text> : null}
183    {tip ? <Box flexShrink={0}><Text color={THEME.onSurface} backgroundColor={THEME.bg}>{` ${tip.label}`}</Text></Box> : null}
184    {tip ? sepText({ Text }, ` ${MARK.sep} ${tip.text}`, THEME.muted, `${key}-text`, { backgroundColor: THEME.bg, wrap: 'truncate-end' }) : <Text backgroundColor={THEME.bg}> </Text>}
185  </Box>
186)
187
188// Pinned at the pane bottom (scroll offset plus body rows) on its own background, so no row shows through; the row under the rule stays blank with no tip, so nothing jumps.
189export function paneFooter(elements: Elements, tip: Tip | null, columns: number, scroll: { offset: number; bodyRows: number }, reveals: Reveal[] = []) {
190  const { Box, Text } = elements
191  return (
192    <Box key="pane-footer" position="absolute" top={Math.max(0, scroll.offset + scroll.bodyRows - FOOTER_ROWS)} left={0} width={columns} height={FOOTER_ROWS} flexDirection="column" backgroundColor={THEME.bg}>
193      <Text key="footer-rule" color={THEME.border} backgroundColor={THEME.bg}>{MARK.rule.repeat(Math.max(1, columns))}</Text>
194      {tipRow(elements, 'footer-tip', tip, columns)}
195      {reveals.map((reveal, at) =>
196        tipRow(elements, `footer-reveal-${at}`, reveal.tip, columns, { position: 'absolute', top: 1, left: 0, display: 'none', hover: { scope: reveal.scope, display: 'flex' } }),
197      )}
198    </Box>
199  )
200}
201
202// A pill's leading icon, mark or digit, or its count: a rounded subpill washed from `hue` into the `fill` it sits on.
203export function subpill({ Box, Text }: Elements, nerd: boolean, fill: string, text: string, key: string, hue: string = THEME.accent) {
204  const bg = mix(hue, fill, WASH)
205  return (
206    <Box key={key} flexDirection="row" flexShrink={0}>
207      {nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={fill}>{CAPS.left}</Text> : null}
208      <Text key={`${key}-text`} color={hue} backgroundColor={bg} bold>{nerd ? text : ` ${text} `}</Text>
209      {nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={fill}>{CAPS.right}</Text> : null}
210    </Box>
211  )
212}
213
214// A section's heading: a surface3 pill led by its icon as a subpill in Nerd mode, bold accent text in safe mode.
215export function heading(elements: Elements, look: Look, key: string, icon: string, title: string) {
216  const { Box, Text } = elements
217  if (!look.nerd) {
218    return (
219      <Box key={`${key}-head`}>
220        <Text color={THEME.accent} bold>{title}</Text>
221      </Box>
222    )
223  }
224  const glyph = look.glyph.icon[icon]
225  return (
226    <Box key={`${key}-head`} flexDirection="row">
227      <Text color={THEME.surface3}>{CAPS.left}</Text>
228      <Box key={`${key}-head-body`} flexDirection="row" backgroundColor={THEME.surface3}>
229        {glyph ? subpill(elements, true, THEME.surface3, glyph, `${key}-head-icon`) : null}
230        <Text color={THEME.onSurface} bold>{glyph ? ` ${title}` : title}</Text>
231      </Box>
232      <Text color={THEME.surface3}>{CAPS.right}</Text>
233    </Box>
234  )
235}
236
237// A card's fill: surface1 washed lightly toward its hue, under a neutral border. Pills on the card sit on it.
238export const cardFill = (hue: string = THEME.accent) => mix(THEME.surface1, hue, 0.06)
239
240// The row above a card, painted on bg so a scrolled card above never shows through the gap.
241export function cardGap({ Box }: Elements, key: string, bg: string = THEME.bg) {
242  return <Box key={`${key}-gap`} height={1} flexShrink={0} backgroundColor={bg} />
243}
244
245// A card's title laid over its top border after the corner (`top` is the border's row); Pane refuses borderTop, so the border stays whole underneath.
246export function titleEdge(elements: Elements, look: Look, key: string, icon: string, title: string, hint = '', top = 1, edge: string = THEME.border) {
247  const { Box, Text } = elements
248  const glyph = look.nerd ? look.glyph.icon[icon] : undefined
249  return (
250    <Box key={`${key}-edge`} position="absolute" top={top} left={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
251      <Text color={edge}>{`${MARK.rule} `}</Text>
252      {glyph ? <Text color={THEME.accent}>{`${glyph} `}</Text> : null}
253      {sepText(elements, title, THEME.onSurface, `${key}-title`, { bold: true })}
254      {hint ? sepText(elements, ` ${hint}`, THEME.muted, `${key}-hint`) : null}
255      <Text> </Text>
256    </Box>
257  )
258}
259
260// A round card tinted toward `hue`, its title (and muted `hint`) set into the top edge, `right` (such as Fold all) at that edge's end.
261export function section(elements: Elements, look: Look, key: string, icon: string, title: string, body: unknown, hue: string = THEME.accent, { hint = '', right }: { hint?: string; right?: unknown } = {}) {
262  const { Box } = elements
263  return (
264    <Box key={`${key}-block`} flexDirection="column">
265      {cardGap(elements, key)}
266      <Box key={key} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill(hue)} paddingX={1}>
267        {body}
268      </Box>
269      {title ? titleEdge(elements, look, key, icon, title, hint) : null}
270      {right ? (
271        <Box key={`${key}-edge-right`} position="absolute" top={1} right={1} flexDirection="row" height={1} backgroundColor={THEME.bg}>
272          {right}
273        </Box>
274      ) : null}
275    </Box>
276  )
277}
278
279
hooks/settings.tsx 297 lines
1// hooks/settings.tsx, by v-amorim
2// Shared settings body; every mod carries the same copy.
3import type { ConfigRow, ConfigValue, RenderElement } from 'claude-code'
4
5import type { EntryProps, Tip } from './entry'
6import { cardFill, cardGap, section, sepText, type Look, type Reveal } from './look'
7import style from './style'
8import { CAPS, fill, MARK, THEME } from './theme'
9
10// A button a mod offers beside its settings: runs `/<command> <args>`, declared in its showcase.json.
11// isLocal marks the drawing mod's own command, which it calls directly, so Claude working does not hold it.
12export type Action = { label: string; command: string; args?: string; isLocal?: boolean }
13
14type Elements = { Box: (props: any) => RenderElement; Button: (props: any) => RenderElement; Client: (props: any) => RenderElement; Text: (props: any) => RenderElement }
15
16export type SettingsProps = {
17  // The mod's own /config rows (`<plugin>.<field>`), as $.config.list() returns them.
18  rows: ConfigRow[]
19  actions: Action[]
20  // Another mod's command run while Claude works only queues as a prompt, so those actions go inert.
21  working: boolean
22  run: (action: Action) => unknown
23  // The animations setting is off: a clicked control does not shimmer.
24  still?: boolean
25  // Hover tips by setting field (the key after the dot); a setting without one shows its description's first sentence.
26  tips?: Record<string, string>
27  // The mod's example cards, from its showcase.json, so its own panel and mod-manager draw the same ones.
28  previews?: Previews
29  // The mod's plugin.json userConfig, read by its own panel and by mod-manager alike, for the min and max a number setting declares.
30  fields?: Record<string, Range>
31  // Text settings (by field) another mod edits in its own panel: the card shows a pill running that panel's command in place of "edit it in /config".
32  editors?: Record<string, Action>
33}
34
35export type Range = { min?: number; max?: number }
36
37// A title's trailing "(unit)" is what its number reads in; the head drops it and the value carries it.
38export function splitTitle(label: string) {
39  const match = /^(.*?)\s*\(([^()]+)\)$/.exec(label)
40  return match ? { name: match[1]!, unit: match[2]! } : { name: label, unit: '' }
41}
42
43// Large round counts read short, in thousands or millions.
44const short = (n: number) => (n >= 1_000_000 && n % 100_000 === 0 ? `${n / 1_000_000}M` : n >= 10_000 && n % 1000 === 0 ? `${n / 1000}K` : String(n))
45
46export function showNumber(value: number, unit: string) {
47  if (!unit) return String(value)
48  if (unit === '%') return `${value}%`
49  return `${short(value)} ${value === 1 ? unit.replace(/s$/, '') : unit}`
50}
51
52// About twenty clicks from one end of the range to the other, on a 1, 2 or 5 step; no range steps by one.
53export function stepOf({ min, max }: Range = {}) {
54  if (min === undefined || max === undefined || max <= min) return 1
55  const raw = (max - min) / 20
56  const power = 10 ** Math.floor(Math.log10(raw))
57  const nearest = [1, 2, 5, 10].map(factor => factor * power).reduce((best, step) => (Math.abs(step - raw) < Math.abs(best - raw) ? step : best))
58  return Math.max(1, nearest)
59}
60
61export function stepNumber(value: number, by: 1 | -1, range: Range = {}) {
62  return Math.min(range.max ?? Infinity, Math.max(range.min ?? -Infinity, value + by * stepOf(range)))
63}
64
65type ThemeColor = keyof typeof THEME
66
67// A literal run of an example line, as the mod's previews test drew it from its real tree; a color is a theme name or a #hex.
68export type PreviewSpan = { text: string; color?: string; bg?: string; bold?: boolean }
69export type PreviewLine = { spans: PreviewSpan[] }
70export type Preview = { frame?: { border: string; bg: string }; nerd: PreviewLine[]; safe: PreviewLine[] }
71
72// showcase.json's "previews", written by `just previews`: per setting field, per value as text ("true", "false", an option), the card in each icon mode.
73export type Previews = Record<string, Record<string, Preview>>
74
75const shade = (color: string | undefined) => (color === undefined ? undefined : color in THEME ? THEME[color as ThemeColor] : color)
76
77// What the row's current value looks like, as a card under it; null when the mod drew none for that value.
78function exampleOf({ Box, Text }: Elements, look: Look, previews: Previews | undefined, row: ConfigRow, index: number) {
79  const entry = previews?.[row.key.slice(row.key.indexOf('.') + 1)]?.[String(row.value)]
80  const lines = look.nerd ? entry?.nerd : entry?.safe
81  if (!entry || !lines?.length) return null
82  // The lines carry their own padding and strip, so the frame adds none. The example sinks to bg, so it never matches the card it sits in.
83  const sunk = (color: string | undefined) => (color !== undefined && color === shade(entry.frame?.bg) ? THEME.bg : color)
84  return (
85    <Box key={`setting-preview-${index}`} flexDirection="column" borderStyle="round" borderColor={shade(entry.frame?.border) ?? THEME.border} backgroundColor={THEME.bg} marginTop={1}>
86      {lines.map((line, index) => (
87        <Text key={`line-${index}`} wrap="truncate-end">
88          {line.spans.length === 0 ? ' ' : line.spans.map((span, at) => <Text key={`span-${at}`} color={shade(span.color) ?? THEME.onSurface} {...(span.bg ? { backgroundColor: sunk(shade(span.bg)) } : {})} {...(span.bold ? { bold: true } : {})}>{span.text}</Text>)}
89        </Text>
90      ))}
91    </Box>
92  )
93}
94
95// The value as the panel shows it: on/off as words, a list as its items.
96const shown = (row: ConfigRow) =>
97  row.kind === 'boolean' ? (row.value === true ? style.text.on : style.text.off) : Array.isArray(row.value) ? row.value.join(', ') : String(row.value)
98
99// Words that stay capitalized when a description follows a tip's label and colon.
100const PROPER = new Set(['Claude', 'Haiku', 'Opus', 'Sonnet', 'Windows', 'Nerd'])
101
102// A setting's description as the tip after "Label: ": its first sentence, led by a lowercase letter unless that word is a proper noun.
103export function descriptionTip(description: string) {
104  const sentence = description.split('. ')[0]!.replace(/\.$/, '')
105  const word = /^[A-Z][a-z]+\b/.exec(sentence)?.[0]
106  return word && !PROPER.has(word) ? sentence[0]!.toLowerCase() + sentence.slice(1) : sentence
107}
108
109// A control is an Entry pill posting `setting:<key>=<value as JSON>`; the mod's ui.message hook saves it with settingChoice.
110const SETTING = 'setting:'
111const settingId = (row: ConfigRow, value: ConfigValue) => `${SETTING}${row.key}=${JSON.stringify(value)}`
112
113// The change a clicked settings control asks for, or null when the message is something else.
114export function settingChoice(data: unknown): { key: string; value: ConfigValue } | null {
115  const message = data as { type?: string; id?: string } | undefined
116  if (message?.type !== 'menu-entry' || !message.id?.startsWith(SETTING)) return null
117  const rest = message.id.slice(SETTING.length)
118  const at = rest.indexOf('=')
119  if (at === -1) return null
120  try {
121    return { key: rest.slice(0, at), value: JSON.parse(rest.slice(at + 1)) as ConfigValue }
122  } catch {
123    return null
124  }
125}
126
127// A pill already frames the glyph, so drop the brackets and padding of the standalone button (safe `[↻]`).
128const bare = (glyph: string) => glyph.replace(/[[\]\s]/g, '')
129
130// A pill on the card's tint, capped where the font has the caps; `hover` lights the caps with a Button scoped to the pill's key.
131const pillOn = ({ Box, Text }: Elements, look: Look, key: string, bg: string, body: unknown, on = THEME.surface1, hover?: string) => {
132  const lit = hover ? { hover: { scope: key, color: hover } } : {}
133  return (
134    <Box key={key} flexDirection="row" flexShrink={0}>
135      {look.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={on} {...lit}>{CAPS.left}</Text> : null}
136      {body}
137      {look.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={on} {...lit}>{CAPS.right}</Text> : null}
138    </Box>
139  )
140}
141
142export type Choice = { key: string; isOn: boolean; props: Pick<EntryProps, 'id' | 'label' | 'still' | 'tip' | 'subLead' | 'subLeadColor'> }
143
144// A control track: a surface3 pill on `on`, padded by a space each side in safe mode, where it has no caps.
145function track(elements: Elements, look: Look, key: string, on: string, parts: unknown[]) {
146  const { Box, Text } = elements
147  const space = (side: string) => (look.nerd ? null : <Text key={`${key}-space-${side}`} backgroundColor={THEME.surface3}> </Text>)
148  return pillOn(elements, look, `${key}-bar`, THEME.surface3, <Box key={`${key}-options`} flexDirection="row" backgroundColor={THEME.surface3}>{[space('left'), ...parts, space('right')]}</Box>, on)
149}
150
151// A choice in a track: the pick a lifted (surface-4) subpill in primary-70 (`ok`), the rest muted; `on` is what the track sits on.
152export function choiceBar(elements: Elements, look: Look, key: string, on: string, choices: Choice[]) {
153  const { Client } = elements
154  const entry = (choice: Choice, shape: Partial<EntryProps>) => (
155    <Client key={choice.key} module="./entry.tsx" height={1} props={{ ...choice.props, isOpen: choice.isOn, bg: THEME.surface3, ...shape } satisfies EntryProps} />
156  )
157  const options = choices.map(choice =>
158    choice.isOn
159      ? // Tight, so the subpill is as wide as an idle option: the caps (Nerd) or a space (safe) stand where its padding was.
160        pillOn(elements, look, `${choice.key}-chosen`, THEME.lifted, entry(choice, { bg: THEME.lifted, color: THEME.ok, idle: THEME.muted, nerd: look.nerd, tight: true }), THEME.surface3)
161      : entry(choice, { idle: THEME.muted }),
162  )
163  return track(elements, look, key, on, options)
164}
165
166const actionScope = (index: number) => `action-row-${index}`
167
168// The tip each live action shows in the pane footer on hover; a Button cannot post one, so paneFooter reveals it by the pill's hover group.
169export const actionReveals = (actions: Action[], look: Look, working: boolean): Reveal[] =>
170  actions.flatMap((action, index) =>
171    working && !action.isLocal ? [] : [{ scope: actionScope(index), tip: { glyph: bare(look.glyph.go), label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }],
172  )
173
174const editorScope = (index: number) => `editor-row-${index}`
175
176const fieldOf = (row: ConfigRow) => row.key.slice(row.key.indexOf('.') + 1)
177
178// The footer tip of each live editor pill, like actionReveals.
179export const editorReveals = (rows: ConfigRow[], editors: Record<string, Action>, look: Look, working: boolean): Reveal[] =>
180  rows.flatMap((row, index) => {
181    const action = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
182    return action && !working ? [{ scope: editorScope(index), tip: { glyph: MARK.open, label: action.label, text: fill(style.text.runsCommand, { command: [action.command, action.args].filter(Boolean).join(' ') }) } }] : []
183  })
184
185// A pill with a plain Button so the press is a real one; the glyph sits in the label so the whole pill lights.
186const buttonPill = (elements: Elements, look: Look, scope: string, key: string, glyph: string, label: string, onPress: () => unknown) => {
187  const { Button } = elements
188  return pillOn(
189    elements,
190    look,
191    scope,
192    THEME.surface3,
193    <Button key={key} label={look.nerd ? `${glyph} ${label}` : ` ${glyph} ${label} `} plain hover={{ scope, color: THEME.accent, inverse: true }} onPress={onPress} />,
194    THEME.surface1,
195    THEME.accent,
196  )
197}
198
199// Edits the row in place: on/off flips, a choice is a bar, a number steps; text is edited in /config.
200function control(elements: Elements, look: Look, row: ConfigRow, index: number, still: boolean, tips?: Record<string, string>, fields?: Record<string, Range>) {
201  const { Client, Text } = elements
202  const key = `setting-${index}`
203  const text = tips?.[row.key.slice(row.key.indexOf('.') + 1)] ?? (row.description ? descriptionTip(row.description) : '')
204  // Client props refuse an undefined value, so no tip text means no key.
205  const tipFor = (glyph: string) => (text ? { tip: { glyph: bare(glyph), label: splitTitle(row.label).name, text } satisfies Tip } : {})
206  const entry = (suffix: string, label: string, value: ConfigValue, bg: string, color: string, isOpen: boolean, glyph = look.glyph.cycle) => (
207    <Client key={`${key}-${suffix}`} module="./entry.tsx" height={1} props={{ id: settingId(row, value), label, isOpen, bg, color, idle: color, still, ...tipFor(glyph) } satisfies EntryProps} />
208  )
209  if (row.isLocked) return <Text color={THEME.muted}>{`${shown(row)} ${bare(look.glyph.locked)} ${style.text.locked}`}</Text>
210  if (row.kind === 'boolean') {
211    // The switch: `━━●` on, `●━━` off, the knob a bare glyph joined to the line.
212    const isOn = row.value === true
213    const shape = isOn
214      ? { lead: MARK.track, leadColor: THEME.accent, label: MARK.knob, idle: THEME.ok }
215      : { lead: MARK.knob, leadColor: style.colors.pills.readout, label: MARK.track, idle: THEME.trackOff }
216    const flip = <Client key={`${key}-flip`} module="./entry.tsx" height={1} props={{ id: settingId(row, !isOn), isOpen: false, bg: THEME.surface3, tight: true, still, ...shape, ...tipFor(look.glyph.cycle) } satisfies EntryProps} />
217    return track(elements, look, key, cardFill(), [flip])
218  }
219  if (row.kind === 'choice' && row.options?.length) {
220    const choices = row.options.map((option, at) => ({ key: `${key}-option-${at}`, isOn: option === String(row.value), props: { id: settingId(row, option), label: option, still, ...tipFor(look.glyph.cycle) } }))
221    return choiceBar(elements, look, key, cardFill(), choices)
222  }
223  if (row.kind === 'number' && typeof row.value === 'number') {
224    const range = fields?.[row.key.slice(row.key.indexOf('.') + 1)]
225    const value = <Text key={`${key}-value`} color={THEME.ok} backgroundColor={THEME.lifted} bold>{look.nerd ? showNumber(row.value, splitTitle(row.label).unit) : ` ${showNumber(row.value, splitTitle(row.label).unit)} `}</Text>
226    return track(elements, look, key, cardFill(), [
227      entry('down', bare(look.glyph.down), stepNumber(row.value, -1, range), THEME.surface3, THEME.muted, false, look.glyph.down),
228      pillOn(elements, look, `${key}-value-pill`, THEME.lifted, value, THEME.surface3),
229      entry('up', bare(look.glyph.up), stepNumber(row.value, 1, range), THEME.surface3, THEME.muted, false, look.glyph.up),
230    ])
231  }
232  return <Text color={THEME.secondary}>{shown(row)}</Text>
233}
234
235// A setting's first row: the label bold on the left, its control anchored right.
236export const settingHead = ({ Box, Text }: Pick<Elements, 'Box' | 'Text'>, key: string, label: string, control: unknown) => (
237  <Box key={key} flexDirection="row" columnGap={2} justifyContent="space-between">
238    <Text color={THEME.onSurface} bold>{label}</Text>
239    {control}
240  </Box>
241)
242
243// A hairline across its box, under a setting's head row; a Client, since only a Client learns the width it gets.
244export const hairline = ({ Client }: Pick<Elements, 'Client'>, key: string, bg: string) => (
245  <Client key={key} module="./entry.tsx" width="100%" height={1} props={{ id: key, label: '', isOpen: false, bg, rule: true } satisfies EntryProps} />
246)
247
248// The Settings and Actions sections of a mod's panel or page: one round-bordered card per setting.
249export function settingsBody(elements: Elements, look: Look, { rows, actions, working, run, previews, tips, fields, editors = {}, still = false }: SettingsProps) {
250  const { Box, Text } = elements
251  const settings = [
252    rows.length === 0 ? <Text key="settings-none" color={THEME.muted}>{style.text.noSettings}</Text> : null,
253    ...rows.map((row, index) => {
254      const example = exampleOf(elements, look, previews, row, index)
255      const editor = row.kind === 'text' && !row.isLocked ? editors[fieldOf(row)] : undefined
256      const head = editor && !working ? (
257        <Box key={`setting-control-${index}`} flexDirection="row" columnGap={1}>
258          {control(elements, look, row, index, still, tips, fields)}
259          {buttonPill(elements, look, editorScope(index), `setting-editor-${index}`, MARK.open, editor.label, () => run(editor))}
260        </Box>
261      ) : (
262        control(elements, look, row, index, still, tips, fields)
263      )
264      const hint = row.kind !== 'text' ? '' : editor ? (working ? style.text.idleOnly : '') : style.text.editInConfig
265      const note = [row.description, hint].filter(Boolean).join(` ${MARK.sep} `)
266      return (
267        <Box key={`setting-row-${index}`} flexDirection="column" borderStyle="round" borderColor={THEME.border} backgroundColor={cardFill()} paddingX={1} marginTop={index === 0 ? 0 : 1}>
268          {settingHead(elements, `setting-head-${index}`, splitTitle(row.label).name, head)}
269          {row.description || example ? hairline(elements, `setting-rule-${index}`, cardFill()) : null}
270          {row.description ? (
271            sepText(elements, note, THEME.muted, `setting-text-${index}`)
272          ) : null}
273          {example}
274        </Box>
275      )
276    }),
277  ]
278  const buttons = actions.map((action, index) =>
279    working && !action.isLocal ? (
280      sepText(elements, `${look.glyph.go} ${action.label} ${MARK.sep} ${style.text.idleOnly}`, THEME.muted, `action-${index}`)
281    ) : (
282      buttonPill(elements, look, actionScope(index), `action-${index}`, bare(look.glyph.go), action.label, () => run(action))
283    ),
284  )
285  return (
286    <Box key="settings" flexDirection="column">
287      {/* The cards carry their own border, and the panel's open Settings tab already names them. */}
288      <Box key="settings-cards" flexDirection="column">
289        {cardGap(elements, 'settings')}
290        {settings}
291      </Box>
292      {actions.length > 0 ? section(elements, look, 'actions', 'actions', style.text.actions, buttons) : null}
293    </Box>
294  )
295}
296
297
hooks/theme.ts 46 lines
1// hooks/theme.ts, by v-amorim
2// Shared theme; every mod carries the same copy. Colors, marks and glyphs live in style.ts.
3import style from './style'
4
5export const THEME = style.colors.theme
6
7// Shell command roles: what runs, its options, quoted text, variables, numbers and the glue between steps.
8export const SYNTAX = style.colors.syntax
9
10// One cell each and never an emoji: ⚠ switches to its emoji form in some fonts and draws two cells wide.
11export const MARK = style.marks
12
13// style.ts writes Nerd glyphs as {U+XXXX}, since private-use characters do not survive many editors and tools.
14const decode = (text: string) => text.replace(/\{U\+([0-9A-F]+)\}/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
15
16export function decodeAll<T>(value: T): T {
17  if (typeof value === 'string') return decode(value) as T
18  if (Array.isArray(value)) return value.map(decodeAll) as T
19  return Object.fromEntries(Object.entries(value as object).map(([key, inner]) => [key, decodeAll(inner)])) as T
20}
21
22// A pill's rounded ends, drawn only where the font has them.
23export const CAPS = decodeAll(style.caps)
24
25export const statusColor = (note: string) =>
26  note.startsWith(MARK.ok) ? THEME.good : note.startsWith(MARK.warn) ? THEME.alert : THEME.muted
27
28const channels = (hex: string) => [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16))
29const isHex = (color: string) => /^#[0-9a-f]{6}$/i.test(color)
30
31// The color `t` of the way from `from` to `to`; a color that is not #RRGGBB switches halfway.
32export function mix(from: string, to: string, t: number) {
33  if (t <= 0) return from
34  if (t >= 1) return to
35  if (!isHex(from) || !isHex(to)) return t < 0.5 ? from : to
36  const end = channels(to)
37  return `#${channels(from).map((value, at) => Math.round(value + (end[at]! - value) * t).toString(16).padStart(2, '0')).join('')}`
38}
39
40// The share of a hue left in a subpill or count badge, washed into the fill it sits on.
41export const WASH = 0.72
42
43// A text.ts string with its {name} slots filled; a slot with no value stays as written, so a missing one shows.
44export const fill = (template: string, values: Record<string, string | number> = {}) =>
45  template.replace(/\{(\w+)\}/g, (slot, name: string) => (name in values ? String(values[name]) : slot))
46
hooks/entry.tsx 241 lines
1// hooks/entry.tsx, by v-amorim
2// Shared pill entry; every mod that draws a menu entry carries the same copy.
3import type { ClientModule, ClientSurface } from 'claude-code'
4
5import style from './style'
6import { CAPS, mix, THEME, WASH } from './theme'
7
8// color: open text (a red Run); idle: closed text, muted unless set; isBusy shimmers the label until the click's result lands.
9// still: the mod's animations setting is off, so nothing shimmers and the hover color switches at once.
10// lead: a bare glyph before the label in leadColor (after a space, none when tight: the cap is the gap); subLead: an icon before it as a rounded subpill in subLeadColor;
11// badge: a count after it in its own pill. Subpills are capped where `nerd` has the caps.
12// tight: no label padding; tip: posts `tip` instead of `menu-hover`; rail: a divider (a Text between Clients squeezes them); rule: a full-width hairline.
13// spinner: the braille dots lead the label in accent while work runs (write the label without its …); still holds the first frame.
14export type EntryProps = {
15  id: string
16  label: string
17  isOpen: boolean
18  bg: string
19  color?: string
20  idle?: string
21  isBusy?: boolean
22  still?: boolean
23  lead?: string
24  leadColor?: string
25  subLead?: string
26  subLeadColor?: string
27  badge?: { text: string; color: string; bg: string }
28  nerd?: boolean
29  tight?: boolean
30  tip?: Tip
31  rail?: boolean
32  rule?: boolean
33  spinner?: boolean
34  compact?: boolean // false: the pill row may cut the entry to its lead when the row is wider than the band
35}
36
37const entryText = (props: EntryProps) => {
38  const pad = props.tight ? (props.isOpen && !props.nerd ? ' ' : '') : ' '
39  return `${pad}${props.compact && props.lead ? '' : props.label}${props.tight && props.badge ? ' ' : pad}`
40}
41
42// The cells an entry takes; subpills add their two caps, or their two padding spaces in safe mode.
43export const entryCells = (props: EntryProps) => {
44  if (props.rail) return 1
45  const cells = (text: string) => [...text].length
46  const leadText = props.lead ?? (props.spinner ? SPIN_FRAMES[0]! : '')
47  const lead = leadText ? cells(leadText) + (props.tight ? 0 : 1) : 0
48  return lead + (props.subLead ? cells(props.subLead) + 2 : 0) + cells(entryText(props)) + (props.badge ? cells(props.badge.text) + 2 : 0)
49}
50
51// A hovered control's glyph, label and what it does; the pane footer and the band's tip row both show it.
52export type Tip = { glyph: string; label: string; text: string }
53
54// Posted to the owning mod's ui.message when the pointer enters a control (id set) or leaves it (id null).
55export type TipMessage = { type: 'tip'; id: string | null; glyph?: string; label?: string; text?: string }
56
57export const isTipMessage = (data: unknown): data is TipMessage => (data as TipMessage | undefined)?.type === 'tip'
58
59// The band's tip row text for a tip message: "Label: what it does", or '' once the pointer left.
60export const tipText = (message: TipMessage | null | undefined) => (message?.id && message.label ? `${message.label}: ${message.text ?? ''}` : '')
61
62// A control calls this from onPointer, with its tip on enter and null on leave; never also `menu-hover`, one post per frame lands.
63export const postTip = (surface: { post: (data: any) => void }, id: string, tip: Tip | null) => {
64  surface.post((tip ? { type: 'tip', id, glyph: tip.glyph, label: tip.label, text: tip.text } : { type: 'tip', id: null }) satisfies TipMessage)
65}
66
67// menu-hover tells the owning mod the pointer arrived or left, so it can show its tip under the row.
68export type EntryMessage = { type: 'menu-entry'; id: string } | { type: 'menu-hover'; id: string; isOver: boolean }
69
70// A dialog opening under the pointer can swallow the leave event, so a hover with no pointer event for this long ends on its own.
71const HOVER_QUIET_MS = 5000
72
73export const TICK_MS = 140
74
75// The loading shimmer: a soft primary-70 band sweeps the dim text, then a pause.
76const BAND = style.colors.shimmer
77const SWEEP_PAUSE = 8
78
79export const shimmerColor = (at: number, phase: number, length: number) => {
80  const period = length + BAND.length * 2 + SWEEP_PAUSE
81  const head = (phase % period) - BAND.length
82  return BAND[Math.abs(at - head)] ?? style.colors.pills.readout
83}
84
85// The braille dots spinner, one frame every SPIN_MS, in accent.
86export const SPIN_FRAMES = [...style.marks.spinner]
87export const SPIN_MS = 80
88
89// The pointer lights a control at once and its color fades out over this many steps, one every HOVER_TICK_MS.
90export const HOVER_STEPS = 2
91
92export const HOVER_TICK_MS = 60
93
94export { mix }
95
96// One step of a hover fade: full at once while the pointer is on, one step toward 0 once it left.
97export const stepToward = (level: number, isOver: boolean) => (isOver ? HOVER_STEPS : Math.max(0, level - 1))
98
99// Even steps: an eased last step barely moves the color, so the fade seemed to hang at its end.
100export const hoverMix = (from: string, to: string, level: number) => mix(from, to, level / HOVER_STEPS)
101
102// A click shimmers the entry until the mod answers with new props, or this long at most.
103const PRESS_MS = 1200
104
105// pressed: the props a click saw and when it came, cleared once the mod answers.
106type State = { isOver: boolean; at: number; phase: number; level?: number; spin?: number; pressed?: { props: string; at: number } }
107
108const isPressed = (state: State | undefined, props: EntryProps | undefined) =>
109  Boolean(state?.pressed && Date.now() - state.pressed.at < PRESS_MS && state.pressed.props === JSON.stringify(props))
110
111// Each instance's running fade-out timer, started by the leave event and stopped at 0 or when the pointer returns.
112const fades = new WeakMap<object, () => void>()
113
114const startFade = (surface: ClientSurface<State>) => {
115  if (fades.has(surface)) return
116  const stop = surface.every(HOVER_TICK_MS, () => {
117    const { state } = surface
118    if (!state) return
119    const level = stepToward(state.level ?? 0, state.isOver)
120    if (level !== (state.level ?? 0)) surface.setState({ ...state, level })
121    if (state.isOver || level === 0) {
122      stop()
123      fades.delete(surface)
124    }
125  })
126  fades.set(surface, stop)
127}
128
129// The ticker reads the latest props from here, since its closure keeps the first ones.
130const latest = new WeakMap<object, EntryProps>()
131
132// Each instance's running tick: it runs only while a shimmer moves or a hover is watched, so idle pills cost no wakeups.
133const ticks = new WeakMap<object, () => void>()
134
135const startTick = (surface: ClientSurface<State>) => {
136  if (ticks.has(surface)) return
137  const stop = surface.every(TICK_MS, () => {
138    const { state } = surface
139    if (!state) return
140    const now = latest.get(surface)
141    if (!now?.still && (now?.isBusy || isPressed(state, now))) surface.setState({ ...state, phase: state.phase + 1 })
142    else if (state.pressed) surface.setState({ ...state, pressed: undefined })
143    else if (state.isOver && Date.now() - state.at >= HOVER_QUIET_MS) {
144      surface.setState({ ...state, isOver: false, at: Date.now() })
145      if (!now?.still) startFade(surface)
146      const id = now?.id ?? ''
147      if (now?.tip) postTip(surface, id, null)
148      else surface.post({ type: 'menu-hover', id, isOver: false } satisfies EntryMessage)
149    } else if (!state.isOver) {
150      stop()
151      ticks.delete(surface)
152    }
153  })
154  ticks.set(surface, stop)
155}
156
157// Each spinning instance's frame clock, stopped once the entry stops spinning.
158const spins = new WeakMap<object, () => void>()
159
160const startSpin = (surface: ClientSurface<State>) => {
161  if (spins.has(surface)) return
162  const stop = surface.every(SPIN_MS, () => {
163    const { state } = surface
164    const now = latest.get(surface)
165    if (!state || !now?.spinner || now.still) {
166      stop()
167      spins.delete(surface)
168      return
169    }
170    surface.setState({ ...state, spin: (state.spin ?? 0) + 1 })
171  })
172  spins.set(surface, stop)
173}
174
175// A Button inverts under the pointer, so the entry is plain text that lights up like a sidebar row.
176const Entry: ClientModule<EntryProps, State> = (props, surface) => {
177  const { Box, Text } = surface.elements
178  if (props.rail) return <Text color={style.colors.pills.separator} backgroundColor={props.bg}>{style.marks.rail}</Text>
179  if (props.rule) return <Text color={THEME.border} backgroundColor={props.bg}>{style.marks.rule.repeat(Math.max(1, surface.columns))}</Text>
180  const hovered = surface.state?.isOver === true
181  latest.set(surface, props)
182
183  if (!surface.state) surface.setState({ isOver: false, at: 0, phase: 0 })
184  if (props.isBusy && !props.still) startTick(surface)
185  if (props.spinner && !props.still) startSpin(surface)
186
187  surface.onPointer(event => {
188    startTick(surface)
189    const over = event.type !== 'leave'
190    if (over !== hovered) {
191      if (props.tip) postTip(surface, props.id, over ? props.tip : null)
192      else surface.post({ type: 'menu-hover', id: props.id, isOver: over } satisfies EntryMessage)
193    }
194    const isClick = event.type === 'down' && event.button === 'left'
195    const base = surface.state ?? { phase: 0, isOver: false, at: 0 }
196    const level = over === base.isOver ? base.level : over ? HOVER_STEPS : props.still ? 0 : base.level
197    if (!over && !props.still) startFade(surface)
198    surface.setState({ ...base, isOver: over, at: Date.now(), level, pressed: isClick ? { props: JSON.stringify(props), at: Date.now() } : base.pressed })
199    if (isClick) surface.post({ type: 'menu-entry', id: props.id } satisfies EntryMessage)
200  })
201
202  const text = entryText(props)
203  const isShimmering = !props.still && (props.isBusy || isPressed(surface.state, props))
204  const phase = surface.state?.phase ?? 0
205  const label = isShimmering ? (
206    <Box key="label" flexDirection="row">
207      {[...text].map((char, at) => (
208        <Text key={`c${at}`} color={shimmerColor(at, phase, text.length)} backgroundColor={props.bg}>
209          {char}
210        </Text>
211      ))}
212    </Box>
213  ) : (
214    <Text key="label" color={hoverMix(props.isOpen ? (props.color ?? THEME.onSurface) : (props.idle ?? THEME.muted), THEME.accent, surface.state?.level ?? 0)} bold={props.isOpen && Boolean(props.idle)} backgroundColor={props.bg}>
215      {text.split(style.marks.sep).flatMap((part, at) => (at === 0 ? [part] : [<Text key={`sep-${at}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
216    </Text>
217  )
218  const lead = props.spinner ? SPIN_FRAMES[props.still ? 0 : (surface.state?.spin ?? 0) % SPIN_FRAMES.length] : props.lead
219  if (!lead && !props.subLead && !props.badge) return isShimmering ? <Box flexDirection="row">{label}</Box> : label
220
221  const sub = (key: string, text: string, color: string, bg: string) => (
222    <Box key={key} flexDirection="row">
223      {props.nerd ? <Text key={`${key}-left`} color={bg} backgroundColor={props.bg}>{CAPS.left}</Text> : null}
224      <Text key={`${key}-text`} color={color} backgroundColor={bg} bold>{props.nerd ? text : ` ${text} `}</Text>
225      {props.nerd ? <Text key={`${key}-right`} color={bg} backgroundColor={props.bg}>{CAPS.right}</Text> : null}
226    </Box>
227  )
228  const subHue = props.subLeadColor ?? THEME.accent
229  const { badge } = props
230  return (
231    <Box flexDirection="row">
232      {lead ? <Text key="lead" color={props.spinner ? THEME.accent : (props.leadColor ?? THEME.accent)} backgroundColor={props.bg}>{props.tight ? lead : ` ${lead}`}</Text> : null}
233      {props.subLead ? sub('sub-lead', props.subLead, subHue, mix(subHue, props.bg, WASH)) : null}
234      {label}
235      {badge ? sub('badge', badge.text, badge.color, badge.bg) : null}
236    </Box>
237  )
238}
239
240export default Entry
241
hooks/pills.tsx 167 lines
1// hooks/pills.tsx, by v-amorim
2// Shared pill row; every mod that draws in the row carries the same copy.
3import type { RenderElement } from 'claude-code'
4
5import { entryCells, mix, type EntryProps, type Tip } from './entry'
6import style from './style'
7import { CAPS, decodeAll, THEME, WASH } from './theme'
8
9// The menu group on Moonlight surface-3, readouts on surface-2.
10export const PILL = { primary: style.colors.pills.primary, secondary: style.colors.pills.secondary }
11
12// Menu entries left to right, by the id in their `menu-pad-<id>` Box key; an unknown id goes last.
13const ENTRY_ORDER = ['manager', 'skills', 'prompts', 'filetree', 'progress', 'headsup']
14
15type Node = { props?: Record<string, unknown>; children?: unknown[] } | null | undefined
16type Elements = { Box: (props: any) => RenderElement; Text: (props: any) => RenderElement }
17
18const keyOf = (node: unknown) => (node as Node)?.props?.key as string | undefined
19const childrenOf = (node: unknown) => (node as Node)?.children ?? []
20
21// Splits next(e) into the menu entries other mods drew, the rest of the row, what else the band holds, and the hover tip under the row.
22export function splitRow(others: unknown) {
23  if (keyOf(others) !== 'menu-rows') return { group: [] as unknown[], row: [] as unknown[], rest: [others], tip: null as unknown }
24  const children = childrenOf(others)
25  const rest = children.filter(child => keyOf(child) !== 'menu-row' && keyOf(child) !== 'menu-tip')
26  const pills = childrenOf(children.find(child => keyOf(child) === 'menu-row'))
27  const groupPill = pills.find(child => keyOf(child) === 'menu-group')
28  const body = childrenOf(groupPill).find(child => keyOf(child) === 'menu-group-body')
29  return {
30    // A mod still running an older copy may draw `menu-group-sep-*` separators between entries; the group drops them.
31    group: childrenOf(body).filter(child => !keyOf(child)?.startsWith('menu-group-sep')),
32    row: pills.filter(child => keyOf(child) !== 'menu-group'),
33    rest,
34    tip: children.find(child => keyOf(child) === 'menu-tip') ?? null,
35  }
36}
37
38// The one-line tip above the pill row, filled while the pointer is on an entry: its "Name:" in bold accent, the rest muted.
39export const tipLine = ({ Box, Text }: Elements, text: string) => {
40  const at = text.indexOf(': ')
41  return (
42    <Box key="menu-tip" paddingLeft={2} flexDirection="row">
43      {at < 0 ? null : <Text color={THEME.accent} bold>{text.slice(0, at + 1)}</Text>}
44      <Text color={THEME.muted}>
45        {(at < 0 ? text : text.slice(at + 1)).split(style.marks.sep).flatMap((part, index) => (index === 0 ? [part] : [<Text key={`sep-${index}`} color={style.colors.pills.separator}>{style.marks.sep}</Text>, part]))}
46      </Text>
47    </Box>
48  )
49}
50
51// One pill with its own caps where the font has them; `on` is what it sits on, `hover` lights the caps with a Button scoped `key`.
52export function pill({ Box, Text }: Elements, nerd: boolean, bg: string, body: unknown, key = 'menu-pill', on?: string, hover?: string) {
53  const under = on ? { backgroundColor: on } : {}
54  const lit = hover ? { hover: { scope: key, color: hover } } : {}
55  return (
56    <Box key={key} flexDirection="row">
57      {nerd ? <Text key={`${key}-cap-left`} color={bg} {...under} {...lit}>{CAPS.left}</Text> : null}
58      <Box key={`${key}-body`} flexDirection="row" backgroundColor={bg}>
59        {body}
60      </Box>
61      {nerd ? <Text key={`${key}-cap-right`} color={bg} {...under} {...lit}>{CAPS.right}</Text> : null}
62    </Box>
63  )
64}
65
66// mod-menu's `labels`: `always` icon and label, `never` the icon alone, `auto` the icon alone while the row overflows (Nerd only; safe keeps labels).
67export type LabelsMode = 'auto' | 'always' | 'never'
68
69// The value of `{ plugin: 'mod-menu', key: 'labels' }` as a mode; a missing or unknown value is `auto`, so a mod works without mod-menu.
70export const labelsOf = (value: unknown): LabelsMode => (value === 'always' || value === 'never' ? value : 'auto')
71
72type ClientNode = { type: 'Client'; props: { props: Record<string, unknown> } }
73const isClient = (node: unknown): node is ClientNode => (node as { type?: string } | null)?.type === 'Client'
74
75// The cells a drawn row takes with every label shown: a Client that may go compact says its own `cells`, or is an entry.tsx one.
76const cellsOf = (node: unknown): number => {
77  if (Array.isArray(node)) return node.reduce((sum: number, each) => sum + cellsOf(each), 0)
78  if (typeof node === 'string') return [...node].length
79  if (!node || typeof node !== 'object') return 0
80  if (isClient(node)) {
81    const { props } = node.props
82    return typeof props.cells === 'number' ? props.cells : entryCells({ ...(props as EntryProps), compact: false })
83  }
84  // A `menu-flash` word comes and goes with the pointer; counting it would flip the row under the pointer.
85  const kids = childrenOf(node).filter(each => each && !keyOf(each)?.startsWith('menu-flash'))
86  return kids.reduce((sum: number, each) => sum + cellsOf(each), Number((node as Node)?.props?.columnGap ?? 0) * Math.max(0, kids.length - 1))
87}
88
89const compacted = (node: unknown): unknown => {
90  if (Array.isArray(node)) return node.map(compacted)
91  if (!node || typeof node !== 'object') return node
92  if (isClient(node)) return node.props.props.compact === false ? { ...node, props: { ...node.props, props: { ...node.props.props, compact: true } } } : node
93  const kids = (node as Node)?.children
94  return kids ? { ...node, children: kids.map(compacted) } : node
95}
96
97// A panel's entry in the menu group, keyed `menu-pad-<id>` for the row's order; the open one is a pill of its own.
98export function menuEntry({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, id: string, { labels, ...props }: Omit<EntryProps, 'id' | 'bg' | 'tight'> & { labels?: LabelsMode }) {
99  // The open panel reads as selected, like an On switch: ok text on ok washed into the group fill.
100  const bg = props.isOpen ? mix(THEME.ok, PILL.primary, WASH) : PILL.primary
101  const glyph = (style.glyphs.nerd.icon as Record<string, string>)[`menu-${id}`]
102  const icon = nerd && !props.lead && glyph ? decodeAll(glyph) : undefined
103  // The icon sits tight against the cap, so the label that follows brings its own gap.
104  const shown = icon ? { lead: icon, label: labels === 'never' ? '' : ` ${props.label}`, ...(labels === 'auto' ? { compact: false } : {}) } : {}
105  // On Nerd the caps take the label's padding cells, so an entry is as wide as before the caps.
106  const entry = <Client key={`menu-entry-${id}`} module="./entry.tsx" height={1} props={{ id, bg, ...props, ...(props.isOpen ? { color: THEME.ok } : {}), ...shown, nerd, tight: nerd } satisfies EntryProps} />
107  return (
108    <Box key={`menu-pad-${id}`} flexDirection="row">
109      {/* A closed entry gets caps in the group's own color, so opening a panel never shifts its neighbours. */}
110      {pill({ Box, Text }, nerd, bg, entry, props.isOpen ? `menu-open-${id}` : `menu-closed-${id}`, PILL.primary)}
111    </Box>
112  )
113}
114
115// The band above the prompt: what else it holds (Claude Code's own effort line), then the pill row, so the pills sit on the input.
116export function pillRow(elements: Elements & { Client?: (props: any) => RenderElement }, nerd: boolean, group: unknown[], row: unknown[], rest: unknown[], tip: unknown = null, columns?: number) {
117  const { Box, Client, Text } = elements
118  // Load order changes with an install or a toggle, so entries sort by a fixed rank.
119  const rank = (entry: unknown) => {
120    const at = ENTRY_ORDER.indexOf(String(keyOf(entry) ?? '').replace(/^menu-pad-/, ''))
121    return at < 0 ? ENTRY_ORDER.length : at
122  }
123  // Like a choice bar: the entries side by side on one pill, the open panel's lifted into its own, a divider Client between neighbours.
124  const sorted = [...group].sort((a, b) => rank(a) - rank(b))
125  const entries = Client
126    ? sorted.flatMap((entry, at) => (at === 0 ? [entry] : [<Client key={`menu-group-sep-${at}`} module="./entry.tsx" height={1} props={{ id: `sep-${at}`, label: '', isOpen: false, bg: PILL.primary, rail: true } satisfies EntryProps} />, entry]))
127    : sorted
128  const pills = (
129    <Box key="menu-row" flexDirection="row" columnGap={1}>
130      {group.length > 0 ? pill(elements, nerd, PILL.primary, entries, 'menu-group') : null}
131      {row}
132    </Box>
133  )
134  // Measured with every label shown, so a row cut to icons never grows back into a row that does not fit.
135  const fits = columns === undefined || cellsOf(pills) <= columns
136  return (
137    <Box key="menu-rows" flexDirection="column">
138      {rest}
139      {/* Above the row, kept even when empty so nothing above jumps when a tip appears. */}
140      {tip ?? tipLine(elements, ' ')}
141      {fits ? pills : compacted(pills)}
142    </Box>
143  )
144}
145
146// One chip per view of a panel (its list, its settings), standing in for the title: id, label, icon glyph, an optional count and an optional hover tip.
147export type Tab = { id: string; label: string; icon: string; isOpen: boolean; count?: number; tip?: Tip }
148
149// A panel's title line: the open view on surface3 and bold, the others on surface2 and muted; a click swaps the view.
150export function tabRow({ Box, Client, Text }: Elements & { Client: (props: any) => RenderElement }, nerd: boolean, tabs: Tab[], still: boolean) {
151  return (
152    <Box key="tab-row" flexDirection="row" columnGap={1}>
153      {tabs.map(tab => {
154        const bg = tab.isOpen ? THEME.surface3 : THEME.surface2
155        // Client props refuse an undefined value, so a tab without a count or tip leaves the key out.
156        const countColor = tab.isOpen ? THEME.accent : style.colors.pills.readout
157        const badge = tab.count === undefined ? {} : { badge: { text: String(tab.count), color: countColor, bg: mix(countColor, bg, WASH) } }
158        const tip = tab.tip ? { tip: tab.tip } : {}
159        const entry = (
160          <Client key={tab.id} module="./entry.tsx" height={1} props={{ id: tab.id, label: tab.label, isOpen: tab.isOpen, bg, idle: THEME.muted, still, subLead: tab.icon, subLeadColor: THEME.accent, nerd, ...(tab.isOpen ? { color: THEME.secondary } : {}), ...badge, ...tip } satisfies EntryProps} />
161        )
162        return pill({ Box, Text }, nerd, bg, entry, `${tab.id}-pill`)
163      })}
164    </Box>
165  )
166}
167
hooks/toasts.tsx 87 lines
1// 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  })
87
hooks/spend.ts 64 lines
1// 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}
64
hooks/text.ts 25 lines
1// 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}
25
hooks/style.ts 280 lines
1// hooks/style.ts, by v-amorim
2// Shared style; every mod carries the same copy. Change values freely, keep the keys. {U+XXXX} is a Nerd Font code point.
3export default {
4  "colors": {
5    "theme": {
6      "bg": "#0D0E17",
7      "surface1": "#141726",
8      "surface2": "#1C2033",
9      "surface3": "#252A42",
10      "lifted": "#2F3654",
11      "trackOff": "#4B5270",
12      "border": "#242839",
13      "onSurface": "#EEEEFA",
14      "muted": "#9BA3C4",
15      "accent": "#7386D0",
16      "secondary": "#B9C6F5",
17      "good": "#49EF95",
18      "ok": "#A2B0EA",
19      "alert": "#CA5F71",
20      "warn": "#FFCB6B",
21      "folder": "#86E1FC",
22      "pill": "#B9C6F5",
23      "addBg": "#1D3B38",
24      "delBg": "#3D2737"
25    },
26    "syntax": {
27      "command": "#7386D0",
28      "option": "#FFAFAF",
29      "text": "#A5CFFF",
30      "variable": "#79C0FF",
31      "number": "#B0C8FF",
32      "operator": "#CDCFE7"
33    },
34    "pills": {
35      "primary": "#252A42",
36      "secondary": "#1C2033",
37      "separator": "#3B446A",
38      "readout": "#7E86A4"
39    },
40    "shimmer": [
41      "#A2B0EA",
42      "#949FCE",
43      "#8993B9"
44    ],
45    "usage": [
46      "#B9C6F5",
47      "#ACB9E6",
48      "#A5A1E9",
49      "#CF95CA",
50      "#CA5FA6",
51      "#CA5F71"
52    ],
53    "statusline": {
54      "pillBorder": "#3A3D4A",
55      "menuBackground": "#1C1D24",
56      "divergence": "#B86CB3",
57      "behind": "#8A9BE0"
58    },
59    "models": {
60      "opus": "#C3A6FF",
61      "sonnet": "#79C0FF",
62      "haiku": "#7CD591",
63      "inherit": "#9BA3C4"
64    },
65    "kinds": {
66      "thinking": { "hue": "#C3A6FF", "wash": "#494267" },
67      "read": { "hue": "#B9C6F5", "wash": "#464C64" },
68      "search": { "hue": "#A5CFFF", "wash": "#404E67" },
69      "edit": { "hue": "#B9C6F5", "wash": "#4C5167" },
70      "shell": { "hue": "#FFCB6B", "wash": "#5B4D3B" },
71      "web": { "hue": "#79C0FF", "wash": "#324A67" },
72      "agent": { "hue": "#F99BD1", "wash": "#593F59" },
73      "tool": { "hue": "#9BA3C4", "wash": "#3D4155" }
74    }
75  },
76  "tags": {
77    "router": {
78      "color": "#F99BD1",
79      "icon": "◆"
80    },
81    "debug": {
82      "color": "#FF9C8B",
83      "icon": "‼"
84    },
85    "interface": {
86      "color": "#EEB154",
87      "icon": "▪"
88    },
89    "writing": {
90      "color": "#C3C55A",
91      "icon": "≡"
92    },
93    "utility": {
94      "color": "#7CD591",
95      "icon": "☼"
96    },
97    "start": {
98      "color": "#2AD7D7",
99      "icon": "»"
100    },
101    "mods": {
102      "color": "#5DCBFF",
103      "icon": "+"
104    },
105    "review": {
106      "color": "#D1A8FF",
107      "icon": "∴"
108    },
109    "personal": {
110      "color": "#9BA3C4",
111      "icon": "●"
112    }
113  },
114  "levels": {
115    "low": { "glyph": "○", "color": "#ACB9E6" },
116    "medium": { "glyph": "◐", "color": "#A5A1E9" },
117    "high": { "glyph": "●", "color": "#CF95CA" },
118    "xhigh": { "glyph": "◉", "color": "#CA5FA6" },
119    "max": { "glyph": "◈", "color": "#CA5F71" }
120  },
121  "statuslineMarks": {
122    "branch": "⑂",
123    "dirty": "●",
124    "diverged": "⇅",
125    "ahead": "↑",
126    "behind": "↓",
127    "pathSep": "›",
128    "menu": "⋯",
129    "close": "×"
130  },
131  "text": {
132    "settings": "Settings",
133    "actions": "Actions",
134    "noSettings": "No settings.",
135    "on": "On",
136    "off": "Off",
137    "locked": "locked",
138    "editInConfig": "edit it in /config",
139    "idleOnly": "available once Claude is idle",
140    "runsCommand": "runs /{command}"
141  },
142  "marks": {
143    "stage": "▸",
144    "cursor": "❯",
145    "bullet": "•",
146    "ok": "✓",
147    "warn": "!",
148    "sep": "·",
149    "stop": "○",
150    "stopOpen": "●",
151    "rail": "│",
152    "rule": "─",
153    "track": "━━",
154    "knob": "●",
155    "spinner": "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏",
156    "open": "↗",
157    "strip": "▎"
158  },
159  "caps": {
160    "left": "{U+E0B6}",
161    "right": "{U+E0B4}"
162  },
163  "glyphs": {
164    "safe": {
165      "on": "[◉]",
166      "off": "[○]",
167      "go": "[↵]",
168      "reveal": "⊞",
169      "cycle": "[↻]",
170      "down": "[−]",
171      "up": "[+]",
172      "locked": "[■]",
173      "close": "[×]",
174      "warn": "!",
175      "ok": "✓",
176      "expanded": "▾",
177      "collapsed": "▸",
178      "more": "▾",
179      "less": "▴",
180      "back": "<",
181      "title": "▸",
182      "gear": "⊛",
183      "copy": "⧉",
184      "icon": { "cost": "$", "arrow": "→" },
185      "mark": {
186        "thinking": "∴",
187        "read": "≡",
188        "search": "?",
189        "edit": "±",
190        "shell": "$",
191        "web": "@",
192        "agent": "◆",
193        "tool": "•"
194      }
195    },
196    "nerd": {
197      "on": " {U+F205} ",
198      "off": " {U+F204} ",
199      "go": " {U+F054} ",
200      "reveal": "{U+EAF7}",
201      "cycle": " {U+F021} ",
202      "down": " {U+F068} ",
203      "up": " {U+F067} ",
204      "locked": " {U+F023} ",
205      "close": " {U+F00D} ",
206      "warn": "{U+EA6C}",
207      "ok": "{U+EAB2}",
208      "expanded": "{U+EAB4}",
209      "collapsed": "{U+EAB6}",
210      "more": "{U+F0D7}",
211      "less": "{U+F0D8}",
212      "back": "{U+F053}",
213      "title": "{U+F054}",
214      "gear": "{U+F0493}",
215      "copy": "{U+F018F}",
216      "icon": {
217        "settings": "{U+F1DE}",
218        "actions": "{U+F0E7}",
219        "wrapup": "{U+F187}",
220        "features": "{U+F005}",
221        "mod": "{U+F12E}",
222        "installed": "{U+F487}",
223        "arrow": "{U+F061}",
224        "not-installed": "{U+F019}",
225        "icons": "{U+F031}",
226        "updates": "{U+F0AA}",
227        "welcome": "{U+F135}",
228        "apply": "{U+F00C}",
229        "cost": "{U+F155}",
230        "skill": "{U+F0D0}",
231        "prompt": "{U+F075}",
232        "fold": "{U+F066}",
233        "files": "{U+EB86}",
234        "replay": "{U+EA82}",
235        "folder": "{U+EA83}",
236        "folder-open": "{U+EAF7}",
237        "file": "{U+EA7B}",
238        "ext-ts": "{U+E628}",
239        "ext-react": "{U+E625}",
240        "ext-js": "{U+E781}",
241        "ext-json": "{U+E60B}",
242        "ext-md": "{U+E609}",
243        "ext-py": "{U+E73C}",
244        "ext-config": "{U+E615}",
245        "ext-sh": "{U+F489}",
246        "Edit": "{U+F040}",
247        "MultiEdit": "{U+F040}",
248        "NotebookEdit": "{U+F040}",
249        "Write": "{U+F15B}",
250        "Read": "{U+F02D}",
251        "Bash": "{U+F120}",
252        "Grep": "{U+F002}",
253        "Glob": "{U+F002}",
254        "Agent": "{U+EBBC}",
255        "Task": "{U+EBBC}",
256        "Skill": "{U+F0D0}",
257        "WebFetch": "{U+F0AC}",
258        "WebSearch": "{U+F0AC}",
259        "TodoWrite": "{U+F0AE}",
260        "tool": "{U+F0AD}",
261        "menu-skills": "{U+F0D0}",
262        "menu-prompts": "{U+F075}",
263        "menu-filetree": "{U+EB86}",
264        "menu-progress": "{U+EBBC}",
265        "menu-headsup": "{U+EA6C}"
266      },
267      "mark": {
268        "thinking": "{U+F09D1}",
269        "read": "{U+F02D}",
270        "search": "{U+F002}",
271        "edit": "{U+F040}",
272        "shell": "{U+F120}",
273        "web": "{U+F0AC}",
274        "agent": "{U+EBBC}",
275        "tool": "{U+F0AD}"
276      }
277    }
278  }
279}
280
types/index.d.ts 10 lines
1// 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