SLOPSHOPPER

mod-hub

One toolbox button in the desktop footer that shows what every mod reports and opens their panes, also while Claude is working

newpanespinnercommand
★ 2v0.1.0MITupdated 2026-10-16MichaelP17/claude-mods/mod-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-hub
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ 🧰 Mods 0 running · all quiet │ ┃ ⏺ Read(src/auth.ts) │ ┃ No mod has reported anything yet. Mods show ⎿ Read 6 lines │ ┃ up here once they call $.hub.set. ⏺ 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 │ │ › /hub │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
🧰 Mods 0 running · all quiet No mod has reported anything yet. Mods show up here once they call $.hub.set.
README

mod-hub

Gathers the mods in one place in the desktop app. The footer under the prompt has room for little more than the usage line, and a slash command typed while Claude works waits there until the turn ends. mod-hub puts a single 🧰 button in the footer instead of one label per mod; a click opens a pane at once, also while Claude is working.

WhenWhat you see
Always, in the desktop app🧰 at the left of the footer, the usage line of status-band beside it
A mod reports something that needs attentiona count on the button, 🧰 2, yellow when something is urgent, such as the cache about to expire
Click on 🧰, or /hubthe Mods pane: one card per mod with its icon, its status and its actions, the mods that need a look first
Click on an actionthe mod does it, usually opening its own pane, and the hub closes
ModStatusActions
idea-shellideas for this folderOpen
service-radarservices runningOpen
grill-panelanswered questions of the open roundContinue, or Latest round
cache-watchhow long the cache stays warm, the context sizeCompact now between turns, when compacting pays off
session-relaychannel and role, waiting tasks or answersOpen
concurrency-guardsubagents and monitors running—
config-snapshotswhether the config changed since the last snapshotOpen

With mod-hub loaded, those mods leave the desktop footer to it; without it they draw their own labels there, as before. The terminal is unchanged: its footer keeps the mods' labels and the slash commands run at once. /hub opens the pane there too.

No setup needed.

For mod authors

mod-hub adds $.hub to every mod's $. A mod reports its row with $.hub.set({ id, icon, title, status, attention, actions }) whenever it changes, attention being none, info or warning; a later set with the same id replaces the row, $.hub.remove({ id }) drops it. A press on an action raises ui.press for the element <id>:<action key> of mod-hub, which the mod answers with on('ui.press', { plugin: 'mod-hub', element: 'my-mod:open' }, ...).

The mods here do not list mod-hub under dependencies, since a mod with a missing dependency does not load at all. They call $.hub through a cast and catch the error a session without mod-hub raises.

Uninstall

Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. The other mods draw their own footer labels again.

Source 5 files
hooks/register.tsx 167 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { HubEntry } from '../types'
5import { badgeOf, entryOf, sorted, upsert, without } from './entries'
6import { buttonWidthOf, entryCard, headerCard } from './look'
7import { cardWidthOf } from './svg'
8
9const PANE = 'hub'
10const TITLE = 'Mods'
11
12const entries = atom({ plugin: 'mod-hub', key: 'entries' } as const, [])
13
14async function openPane($: EngineInterface): Promise<void> {
15  await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
16}
17
18export const register: Register = on => {
19  // The methods only raise their events; the hooks below answer them with this
20  // mod's own `$`, which an engine.create step does not have.
21  on('engine.create', async (_$, e, next) => {
22    const built = await next(e)
23
24    return {
25      ...built,
26      hub: {
27        set: async () => undefined,
28        remove: async () => undefined,
29      },
30    }
31  })
32
33  on('hub.set', async ($, e) => {
34    const entry = entryOf(e)
35    await update($, entries, list => upsert(list, entry))
36
37    return { value: undefined }
38  })
39
40  on('hub.remove', async ($, e) => {
41    await update($, entries, list => without(list, e.id))
42
43    return { value: undefined }
44  })
45
46  on('session.start', async ($, e, next) => {
47    await $.command.register({
48      name: 'hub',
49      description: 'Show what every mod reports and open their panes',
50      immediate: true,
51    })
52
53    return next(e)
54  })
55
56  on('command.run', { command: 'hub' }, async $ => {
57    await openPane($)
58
59    return {}
60  })
61
62  // The desktop app holds a slash command typed mid-turn until the turn ends; a
63  // press on this button reaches the mod at once. The terminal keeps the mods'
64  // own labels and /hub.
65  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
66    if (e.surface === 'terminal') {
67      return next(e)
68    }
69    const below = await next(e)
70    const { Box, Button, Text } = $.ui.resolve(e)
71    const badge = badgeOf(await read($, entries))
72    const rest = below.type !== 'engine' ? below : e.props.modes.length === 0 ? null : <Text dimColor>{e.props.modes.join(' & ')}</Text>
73
74    return (
75      <Box flexDirection="row" alignItems="center">
76        <Button key="open-hub" onPress={() => openPane($)}>
77          {'🧰'}
78          {badge.count > 0 && <Text color={badge.color}>{` ${badge.count}`}</Text>}
79        </Button>
80        {rest}
81      </Box>
82    )
83  })
84
85  // Each action is pressed as `<mod>:<action>`; the mod hooks `ui.press` on
86  // that element and does the work, then this closure closes the hub.
87  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
88    const ui = $.ui.resolve(e)
89    const { Box, Button, Text } = ui
90    const list = sorted(await read($, entries))
91    const noted = list.filter(i => i.attention !== 'none')
92    const quiet = list.filter(i => i.attention === 'none')
93    const summary = noted.length === 0 ? `${list.length} running · all quiet` : `${noted.length} of ${list.length} need a look`
94    const actionsOf = (entry: HubEntry) =>
95      entry.actions.map((action, index) => (
96        <Button
97          key={`${entry.id}:${action.key}`}
98          label={action.label}
99          {...(index === 0 && entry.attention !== 'none' ? { variant: 'primary' as const } : {})}
100          onPress={() => $.ui.close({ id: PANE })}
101        />
102      ))
103    const section = (label: string, group: readonly HubEntry[], draw: (entry: HubEntry) => RenderElement) =>
104      group.length > 0 && (
105        <Box flexDirection="column" rowGap={1}>
106          {noted.length > 0 && (
107            <Text dimColor bold>
108              {label}
109            </Text>
110          )}
111          {group.map(draw)}
112        </Box>
113      )
114
115    if (e.surface !== 'terminal') {
116      const { Svg } = $.ui.resolve(e)
117      const width = cardWidthOf(e.props.bodyColumns)
118      const header = headerCard({ width, eyebrow: '🧰 MODS', title: list.length === 0 ? 'Nothing reported yet' : summary, counter: `${list.length} loaded` })
119      // The actions lie over the card's right edge: the picture takes no presses.
120      const drawCard = (entry: HubEntry) => {
121        const card = entryCard(width, entry, buttonWidthOf(entry.actions.map(i => i.label)))
122
123        return (
124          <Box key={`row-${entry.id}`} flexDirection="column">
125            <Svg source={card.source} alt={card.alt} width={width} height={card.height} />
126            {entry.actions.length > 0 && (
127              <Box position="absolute" top={0} bottom={0} right={2} flexDirection="row" alignItems="center" columnGap={1}>
128                {actionsOf(entry)}
129              </Box>
130            )}
131          </Box>
132        )
133      }
134
135      return (
136        <Box flexDirection="column" rowGap={1}>
137          <Svg source={header.source} alt={header.alt} width={width} height={header.height} />
138          {list.length === 0 && <Text dimColor>No mod has reported anything yet. Mods show up here once they call $.hub.set.</Text>}
139          {section('NEEDS A LOOK', noted, drawCard)}
140          {section('ALL QUIET', quiet, drawCard)}
141        </Box>
142      )
143    }
144
145    const drawRow = (entry: HubEntry) => (
146      <Box key={`row-${entry.id}`} flexDirection="row" columnGap={1}>
147        <Text>{entry.icon}</Text>
148        <Text bold>{entry.title}</Text>
149        <Text {...(entry.attention === 'none' ? { dimColor: true } : { color: entry.attention === 'warning' ? 'warning' : 'claude' })}>{entry.status}</Text>
150        {actionsOf(entry)}
151      </Box>
152    )
153
154    return (
155      <Box flexDirection="column" rowGap={1}>
156        <Box flexDirection="row" justifyContent="space-between">
157          <Text bold>🧰 Mods</Text>
158          <Text dimColor>{summary}</Text>
159        </Box>
160        {list.length === 0 && <Text dimColor>No mod has reported anything yet. Mods show up here once they call $.hub.set.</Text>}
161        {section('NEEDS A LOOK', noted, drawRow)}
162        {section('ALL QUIET', quiet, drawRow)}
163      </Box>
164    )
165  })
166}
167
hooks/entries.ts 35 lines
1import type { HubEntry } from '../types'
2
3// Copied field by field, so a mod that passes more than the contract holds
4// cannot grow the state.
5export function entryOf(input: HubEntry): HubEntry {
6  return {
7    id: input.id,
8    icon: input.icon,
9    title: input.title,
10    status: input.status,
11    attention: input.attention,
12    actions: input.actions.map(i => ({ key: i.key, label: i.label })),
13  }
14}
15
16export function upsert(list: readonly HubEntry[], entry: HubEntry): HubEntry[] {
17  const index = list.findIndex(i => i.id === entry.id)
18
19  return index === -1 ? [...list, entry] : list.map((value, position) => (position === index ? entry : value))
20}
21
22export function without(list: readonly HubEntry[], id: string): HubEntry[] {
23  return list.filter(i => i.id !== id)
24}
25
26export function sorted(list: readonly HubEntry[]): HubEntry[] {
27  return [...list].sort((left, right) => left.title.localeCompare(right.title))
28}
29
30export function badgeOf(list: readonly HubEntry[]): { count: number; color: string } {
31  const noted = list.filter(i => i.attention !== 'none')
32
33  return { count: noted.length, color: noted.some(i => i.attention === 'warning') ? 'warning' : 'claude' }
34}
35
hooks/look.ts 36 lines
1import type { HubEntry } from '../types'
2import { PAD, escape, fitText, frame, svgDocument } from './svg'
3import type { Card } from './svg'
4
5export { buttonWidthOf, headerCard } from './svg'
6export type { Card } from './svg'
7
8const HEIGHT = 64
9
10const TONE_OF = {
11  none: { dot: 'muted', alt: 'quiet' },
12  info: { dot: 'accent', alt: 'active' },
13  warning: { dot: 'warning', alt: 'needs attention' },
14} as const
15
16// One mod: its icon on a tile, its title and its status; `reserve` keeps the
17// text clear of the native action buttons laid over the card's right edge.
18export function entryCard(width: number, entry: HubEntry, reserve: number): Card {
19  const tone = TONE_OF[entry.attention]
20  const textX = PAD + 46
21  const maxWidth = width - textX - PAD - reserve
22  const middle = HEIGHT / 2
23  const statusX = textX + 12
24  const body = [
25    frame(width, HEIGHT, entry.attention === 'warning' ? 'card-warning' : 'card'),
26    `<rect class="badge" x="${PAD}" y="${middle - 17}" width="34" height="34" rx="9"/>`,
27    `<text x="${PAD + 17}" y="${middle + 6}" font-size="17" text-anchor="middle">${escape(entry.icon)}</text>`,
28    `<text class="t" x="${textX}" y="${middle - 4}" font-size="14" font-weight="650">${escape(fitText(entry.title, maxWidth, 14, true))}</text>`,
29    entry.attention === 'none' ? '' : `<circle class="${tone.dot} ripple" cx="${textX + 4}" cy="${middle + 11}" r="3.5"/>`,
30    `<circle class="${tone.dot}" cx="${textX + 4}" cy="${middle + 11}" r="3.5"/>`,
31    `<text class="${entry.attention === 'none' ? 'muted' : tone.dot}" x="${statusX}" y="${middle + 15}" font-size="12.5">${escape(fitText(entry.status, maxWidth - 12, 12.5))}</text>`,
32  ]
33
34  return { height: HEIGHT, alt: `${entry.title}: ${entry.status} (${tone.alt})`, source: svgDocument(width, HEIGHT, body.join('')) }
35}
36
hooks/svg.ts 603 lines
1// An SVG drawn as an image cannot lay out text: no wrapping, no Markdown, no
2// measuring. This module does that by estimate and stacks the result into
3// cards. Every mod with desktop cards carries the same copy of this file.
4
5export type Run = { text: string; isBold?: boolean; isItalic?: boolean; isCode?: boolean; isLink?: boolean }
6
7export type Block =
8  | { kind: 'paragraph'; runs: Run[] }
9  | { kind: 'heading'; runs: Run[] }
10  | { kind: 'quote'; runs: Run[] }
11  | { kind: 'item'; marker: string; runs: Run[] }
12  | { kind: 'code'; lines: string[] }
13
14export const SANS = '-apple-system,BlinkMacSystemFont,&quot;Segoe UI&quot;,system-ui,sans-serif'
15export const MONO = 'ui-monospace,SFMono-Regular,Menlo,monospace'
16
17// Classes rather than colors: drawn as an image the SVG follows the light or
18// dark color scheme by itself, which a color handed in from the hook could not.
19export const STYLE = `<style>
20text{font-family:${SANS};white-space:pre}.mono{font-family:${MONO}}
21.card{fill:#f7f6f3;stroke:#e5e3de}.card-accent{fill:#fbf1ec;stroke:#e7b49c}.card-success{fill:#f1f8f3;stroke:#a9d6b8}
22.card-warning{fill:#fcf6e6;stroke:#e6cf8a}.card-error{fill:#fcefee;stroke:#e8aaa6}
23.tile{fill:#ffffff;stroke:#e5e3de}.tile-accent{fill:#ffffff;stroke:#d97757}.tile-success{fill:#ffffff;stroke:#2e9e5b}
24.t{fill:#1f1e1d}.muted{fill:#77756f}.eyebrow{fill:#c9643f}.code{fill:#a2482a}.link{fill:#c9643f}
25.success{fill:#2e9e5b}.warning{fill:#a87a06}.error{fill:#c0392b}.accent{fill:#d97757}.on-accent{fill:#ffffff}
26.badge{fill:#ecebe7}.badge-t{fill:#4a4844}.codebg{fill:#efede8}.rule{fill:#dad7d0}.pill{fill:#f6e1d7}.pill-success{fill:#dcefe2}
27.open{fill:#e4e2dc}.answered{fill:#2e9e5b}.skipped{fill:#d4a017}.current{fill:#d97757}
28.current,.pulse{animation:pulse 1.8s ease-in-out infinite}@keyframes pulse{50%{opacity:.5}}
29.ripple{transform-box:fill-box;transform-origin:center;animation:ripple 2.4s ease-out infinite}@keyframes ripple{0%{transform:scale(1);opacity:.45}100%{transform:scale(2.8);opacity:0}}
30.spin{transform-box:fill-box;transform-origin:center;animation:spin .9s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
31.dot{animation:dot 1.2s ease-in-out infinite}.dot2{animation-delay:.2s}.dot3{animation-delay:.4s}@keyframes dot{0%,80%,100%{opacity:.25}40%{opacity:1}}
32@media (prefers-color-scheme: dark){
33.card{fill:#262625;stroke:#363632}.card-accent{fill:#2e2420;stroke:#7a4a36}.card-success{fill:#1f2a23;stroke:#2f5a3d}
34.card-warning{fill:#2c2717;stroke:#6a5a24}.card-error{fill:#2e1f1e;stroke:#6e3430}
35.tile{fill:#1e1e1d;stroke:#3a3a36}.tile-accent{fill:#1e1e1d;stroke:#e08a6a}.tile-success{fill:#1e1e1d;stroke:#3fb36b}
36.t{fill:#ecebe8}.muted{fill:#a5a39d}.eyebrow{fill:#e8916f}.code{fill:#f0a283}.link{fill:#e8916f}
37.success{fill:#4cc27a}.warning{fill:#e2b33c}.error{fill:#e8746a}.accent{fill:#e08a6a}.on-accent{fill:#1f1e1d}
38.badge{fill:#34342f}.badge-t{fill:#d6d4cf}.codebg{fill:#1c1c1b}.rule{fill:#45443f}.pill{fill:#4a3328}.pill-success{fill:#24432f}
39.open{fill:#3a3a37}.answered{fill:#3fb36b}.skipped{fill:#d4a017}.current{fill:#e08a6a}}
40@media (prefers-reduced-motion: reduce){.current,.pulse,.dot,.ripple,.spin{animation:none}}
41</style>`
42
43// Widths are estimated from the font size, on the wide side: a line then ends
44// a little early rather than past the card's edge.
45export function textWidth(text: string, size: number, isBold = false, isMono = false): number {
46  if (isMono) {
47    return Array.from(text).length * size * 0.62
48  }
49  let units = 0
50  for (const char of text) {
51    if (/[\s.,:;'|!ijlI]/u.test(char)) {
52      units += 0.3
53    } else if (/[A-Z@%&MWmw]/u.test(char)) {
54      units += 0.74
55    } else if (/\p{Extended_Pictographic}/u.test(char)) {
56      units += 1.15
57    } else {
58      units += 0.53
59    }
60  }
61
62  return units * size * (isBold ? 1.04 : 1)
63}
64
65function runWidth(run: Run, size: number): number {
66  return textWidth(run.text, size, run.isBold === true, run.isCode === true)
67}
68
69export function fitText(text: string, maxWidth: number, size: number, isBold = false): string {
70  if (textWidth(text, size, isBold) <= maxWidth) {
71    return text
72  }
73  const chars = Array.from(text)
74  while (chars.length > 0 && textWidth(`${chars.join('')}…`, size, isBold) > maxWidth) {
75    chars.pop()
76  }
77
78  return `${chars.join('').trimEnd()}…`
79}
80
81export function escape(text: string): string {
82  return text.replace(/&/gu, '&amp;').replace(/</gu, '&lt;').replace(/>/gu, '&gt;').replace(/"/gu, '&quot;')
83}
84
85const INLINE = /(\*\*[^*]+?\*\*|__[^_]+?__|`[^`]+`|\[[^\]]+\]\([^)\s]+\)|(?<![\w*])\*(?!\s)[^*]+?(?<!\s)\*(?!\w))/gu
86
87export function parseInline(text: string): Run[] {
88  const runs: Run[] = []
89  let last = 0
90  for (const match of text.matchAll(INLINE)) {
91    const [token] = match
92    if (match.index > last) {
93      runs.push({ text: text.slice(last, match.index) })
94    }
95    if (token.startsWith('**') || token.startsWith('__')) {
96      runs.push(...parseInline(token.slice(2, -2)).map(i => ({ ...i, isBold: true })))
97    } else if (token.startsWith('`')) {
98      runs.push({ text: token.slice(1, -1), isCode: true })
99    } else if (token.startsWith('[')) {
100      runs.push({ text: token.slice(1, token.indexOf('](')), isLink: true })
101    } else {
102      runs.push(...parseInline(token.slice(1, -1)).map(i => ({ ...i, isItalic: true })))
103    }
104    last = match.index + token.length
105  }
106  if (last < text.length) {
107    runs.push({ text: text.slice(last) })
108  }
109
110  return runs.filter(i => i.text.length > 0)
111}
112
113export function plainOf(text: string): string {
114  return parseInline(text)
115    .map(i => i.text)
116    .join('')
117}
118
119const ITEM = /^\s*([-*•+]|\d{1,3}[.)])\s+(.*)$/u
120const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/u
121const QUOTE = /^\s*>\s?(.*)$/u
122const FENCE = /^\s*(```|~~~)/u
123
124// Single line breaks join a paragraph, as Markdown reads them; a list item,
125// heading, quote or code fence starts a block of its own.
126export function parseBlocks(markdown: string): Block[] {
127  const blocks: Block[] = []
128  let paragraph: string[] = []
129  let item: { marker: string; text: string[] } | null = null
130  let quote: string[] = []
131  const flush = () => {
132    if (paragraph.length > 0) {
133      blocks.push({ kind: 'paragraph', runs: parseInline(paragraph.join(' ')) })
134    }
135    if (item !== null) {
136      blocks.push({ kind: 'item', marker: item.marker, runs: parseInline(item.text.join(' ')) })
137    }
138    if (quote.length > 0) {
139      blocks.push({ kind: 'quote', runs: parseInline(quote.join(' ')) })
140    }
141    paragraph = []
142    item = null
143    quote = []
144  }
145  const lines = markdown.replace(/\r\n?/gu, '\n').split('\n')
146  for (let index = 0; index < lines.length; index++) {
147    const line = lines[index] ?? ''
148    if (FENCE.test(line)) {
149      flush()
150      const code: string[] = []
151      index += 1
152      while (index < lines.length && !FENCE.test(lines[index] ?? '')) {
153        code.push(lines[index] ?? '')
154        index += 1
155      }
156      blocks.push({ kind: 'code', lines: code })
157      continue
158    }
159    if (line.trim().length === 0) {
160      flush()
161      continue
162    }
163    const heading = HEADING.exec(line)
164    if (heading !== null) {
165      flush()
166      blocks.push({ kind: 'heading', runs: parseInline(heading[1] ?? '') })
167      continue
168    }
169    const listed = ITEM.exec(line)
170    if (listed !== null) {
171      flush()
172      const marker = listed[1] ?? '-'
173      item = { marker: /\d/u.test(marker) ? marker : '•', text: [listed[2] ?? ''] }
174      continue
175    }
176    const quoted = QUOTE.exec(line)
177    if (quoted !== null) {
178      if (quote.length === 0) {
179        flush()
180      }
181      quote.push(quoted[1] ?? '')
182      continue
183    }
184    if (item !== null && /^\s+\S/u.test(line)) {
185      item.text.push(line.trim())
186      continue
187    }
188    if (item !== null || quote.length > 0) {
189      flush()
190    }
191    paragraph.push(line.trim())
192  }
193  flush()
194
195  return blocks
196}
197
198type Word = Run & { hasSpace: boolean }
199
200function wordsOf(runs: readonly Run[]): Word[] {
201  const words: Word[] = []
202  for (const run of runs) {
203    for (const piece of run.text.split(/(\s+)/u)) {
204      if (piece.length === 0) {
205        continue
206      }
207      if (/^\s+$/u.test(piece)) {
208        const previous = words.at(-1)
209        if (previous !== undefined) {
210          previous.hasSpace = true
211        }
212        continue
213      }
214      words.push({ ...run, text: piece, hasSpace: false })
215    }
216  }
217
218  return words
219}
220
221function sameStyle(first: Run, second: Run): boolean {
222  return first.isBold === second.isBold && first.isItalic === second.isItalic && first.isCode === second.isCode && first.isLink === second.isLink
223}
224
225function breakWord(word: Word, maxWidth: number, size: number): Word[] {
226  const pieces: Word[] = []
227  let current = ''
228  for (const char of word.text) {
229    if (current.length > 0 && runWidth({ ...word, text: current + char }, size) > maxWidth) {
230      pieces.push({ ...word, text: current, hasSpace: false })
231      current = ''
232    }
233    current += char
234  }
235  pieces.push({ ...word, text: current })
236
237  return pieces
238}
239
240// Greedy, word by word; a word wider than the line is broken by characters.
241// `firstWidth` keeps the first line clear of a badge drawn at its end.
242export function wrapRuns(runs: readonly Run[], maxWidth: number, size: number, firstWidth = maxWidth): Run[][] {
243  const lines: Word[][] = []
244  let line: Word[] = []
245  let width = 0
246  const space = textWidth(' ', size)
247  const words = wordsOf(runs).flatMap(i => (runWidth(i, size) > maxWidth ? breakWord(i, maxWidth, size) : [i]))
248  for (const word of words) {
249    const previous = line.at(-1)
250    const gap = previous?.hasSpace === true ? space : 0
251    const wordWidth = runWidth(word, size)
252    if (line.length > 0 && width + gap + wordWidth > (lines.length === 0 ? firstWidth : maxWidth)) {
253      lines.push(line)
254      line = []
255      width = 0
256    }
257    width += (line.length > 0 ? gap : 0) + wordWidth
258    line.push(word)
259  }
260  if (line.length > 0) {
261    lines.push(line)
262  }
263
264  // The space after a code span is drawn in the text's font, not the code's
265  // wider one.
266  return lines.map(words => {
267    const merged: Run[] = []
268    const append = (run: Run) => {
269      const previous = merged.at(-1)
270      if (previous !== undefined && sameStyle(previous, run)) {
271        previous.text += run.text
272      } else {
273        merged.push({ ...run })
274      }
275    }
276    words.forEach((word, index) => {
277      const { hasSpace, ...run } = word
278      const hasGap = hasSpace && index < words.length - 1
279      if (run.isCode === true) {
280        append(run)
281        if (hasGap) {
282          append({ text: ' ' })
283        }
284      } else {
285        append({ ...run, text: run.text + (hasGap ? ' ' : '') })
286      }
287    })
288
289    return merged
290  })
291}
292
293// Cuts wrapped lines to `maxLines`, the last one ending in an ellipsis.
294export function clampLines(lines: Run[][], maxLines: number, maxWidth: number, size: number): Run[][] {
295  if (lines.length <= maxLines) {
296    return lines
297  }
298  const kept = lines.slice(0, maxLines)
299  const last = kept[maxLines - 1] ?? []
300  const text = last.map(i => i.text).join('')
301  kept[maxLines - 1] = [{ ...(last[0] ?? { text: '' }), text: fitText(`${text}…`, maxWidth, size, last[0]?.isBold === true) }]
302
303  return kept
304}
305
306export function lineSvg(runs: readonly Run[], x: number, y: number, size: number, tone = 't', weight?: number): string {
307  const spans = runs.map(run => {
308    const classes = run.isCode ? 'code' : run.isLink ? 'link' : tone
309    const attributes = [
310      `class="${classes}"`,
311      run.isBold === true || weight !== undefined ? `font-weight="${run.isBold === true ? 650 : weight}"` : '',
312      run.isItalic === true ? 'font-style="italic"' : '',
313      run.isCode === true ? `font-family="${MONO}" font-size="${(size * 0.92).toFixed(1)}"` : '',
314      run.isLink === true ? 'text-decoration="underline"' : '',
315    ].filter(i => i.length > 0)
316
317    return `<tspan ${attributes.join(' ')}>${escape(run.text)}</tspan>`
318  })
319
320  return `<text x="${x}" y="${y.toFixed(1)}" font-size="${size}">${spans.join('')}</text>`
321}
322
323// A pane reports its width in columns only; about 8 CSS pixels each, as
324// savvy-progress measures it, minus the pane's own inset.
325export function cardWidthOf(columns: number): number {
326  return Math.max(240, Math.min(900, (columns || 40) * 8 - 8))
327}
328
329// The columns a native control laid over a card is pushed in by to clear
330// `pixels` of the card, at the same 8 pixels per column.
331export function columnsOf(pixels: number): number {
332  return Math.ceil(pixels / 8)
333}
334
335// About how wide the desktop draws a row of native buttons with these labels,
336// for text that has to keep clear of buttons laid over a card.
337export function buttonWidthOf(labels: readonly string[]): number {
338  return labels.reduce((sum, label) => sum + textWidth(label, 13) + 30, 0) + Math.max(0, labels.length - 1) * 8
339}
340
341export function svgDocument(width: number, height: number, body: string): string {
342  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${STYLE}${body}</svg>`
343}
344
345export type Segment = 'answered' | 'skipped' | 'current' | 'open'
346
347export type Card = { source: string; height: number; alt: string }
348
349export type Variant = 'card' | 'card-accent' | 'card-success' | 'card-warning' | 'card-error'
350
351export type Section = { height: number; draw: (y: number) => string }
352
353export const PAD = 18
354export const SIZE = 14
355export const LINE = 21
356export const EYEBROW = 11
357export const RADIUS = 14
358
359// The engine refuses an Svg taller than 4096 pixels; a longer card is cut
360// between sections into several cards of the same look.
361export const MAX_HEIGHT = 4000
362
363export function baseline(y: number, line: number, lineHeight = LINE): number {
364  return y + line * lineHeight + lineHeight * 0.72
365}
366
367export function frame(width: number, height: number, variant: Variant): string {
368  return `<rect class="${variant}" x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${RADIUS}"/>`
369}
370
371export function eyebrowSvg(text: string, x: number, y: number, tone = 'eyebrow', anchor = 'start'): string {
372  return `<text class="${tone}" x="${x}" y="${y}" font-size="${EYEBROW}" font-weight="700" letter-spacing="1.1" text-anchor="${anchor}">${escape(text)}</text>`
373}
374
375export function eyebrowWidth(text: string): number {
376  return textWidth(text, EYEBROW, true) + text.length * 1.1
377}
378
379export function textSection(
380  runs: readonly Run[],
381  x: number,
382  maxWidth: number,
383  options: { tone?: string; size?: number; maxLines?: number; weight?: number; firstWidth?: number } = {},
384): Section {
385  const size = options.size ?? SIZE
386  const lineHeight = Math.round(size * 1.5)
387  const wrapped = wrapRuns(runs, maxWidth, size, options.firstWidth)
388  const lines = options.maxLines === undefined ? wrapped : clampLines(wrapped, options.maxLines, maxWidth, size)
389
390  return {
391    height: lines.length * lineHeight,
392    draw: y => lines.map((line, index) => lineSvg(line, x, baseline(y, index, lineHeight), size, options.tone, options.weight)).join(''),
393  }
394}
395
396export function codeSection(lines: readonly string[], x: number, maxWidth: number): Section {
397  const size = 12.5
398  const lineHeight = 18
399  const perLine = Math.max(8, Math.floor((maxWidth - 24) / (size * 0.62)))
400  const rows = lines.flatMap(line => {
401    const chars = Array.from(line)
402    const pieces: string[] = []
403    for (let index = 0; index < Math.max(1, chars.length); index += perLine) {
404      pieces.push(chars.slice(index, index + perLine).join(''))
405    }
406
407    return pieces
408  })
409
410  return {
411    height: rows.length * lineHeight + 20,
412    draw: y =>
413      `<rect class="codebg" x="${x}" y="${y}" width="${maxWidth}" height="${rows.length * lineHeight + 20}" rx="8"/>` +
414      rows.map((row, index) => lineSvg([{ text: row, isCode: true }], x + 12, baseline(y + 10, index, lineHeight), size / 0.92)).join(''),
415  }
416}
417
418export function blockSection(block: Block, x: number, maxWidth: number): Section {
419  switch (block.kind) {
420    case 'code':
421      return codeSection(block.lines, x, maxWidth)
422    case 'heading':
423      return textSection(block.runs, x, maxWidth, { size: SIZE + 1.5, weight: 650 })
424    case 'quote': {
425      const text = textSection(block.runs, x + 14, maxWidth - 14, { tone: 'muted' })
426
427      return { height: text.height, draw: y => `<rect class="rule" x="${x}" y="${y + 2}" width="3" height="${text.height - 4}" rx="1.5"/>${text.draw(y)}` }
428    }
429    case 'item': {
430      const indent = block.marker === '•' ? 16 : textWidth(block.marker, SIZE) + 8
431      const text = textSection(block.runs, x + indent, maxWidth - indent)
432
433      return {
434        height: text.height,
435        draw: y => `<text class="muted" x="${x + (block.marker === '•' ? 3 : 0)}" y="${baseline(y, 0).toFixed(1)}" font-size="${SIZE}">${escape(block.marker)}</text>${text.draw(y)}`,
436      }
437    }
438    default:
439      return textSection(block.runs, x, maxWidth)
440  }
441}
442
443// Paragraphs sit further apart than the items of one list.
444export function blockSections(blocks: readonly Block[], x: number, maxWidth: number): Section[] {
445  return blocks.map((block, index) => {
446    const section = blockSection(block, x, maxWidth)
447    const previous = blocks[index - 1]
448    const spacing = index === 0 ? 0 : previous?.kind === 'item' && block.kind === 'item' ? 4 : 10
449
450    return { height: section.height + spacing, draw: y => section.draw(y + spacing) }
451  })
452}
453
454// Stacks sections inside one frame; past MAX_HEIGHT the rest go on in a next one.
455// `minHeight` keeps a short card tall enough for native controls laid over it.
456export function cards(width: number, variant: Variant, sections: readonly Section[], alt: string, decoration = '', minHeight = 0): Card[] {
457  const result: Card[] = []
458  let pending: Section[] = []
459  let used = 2 * PAD
460  const close = () => {
461    if (pending.length === 0) {
462      return
463    }
464    let y = PAD
465    const body = pending.map(section => {
466      const drawn = section.draw(y)
467      y += section.height
468
469      return drawn
470    })
471    const natural = Math.ceil(y + PAD)
472    const height = result.length === 0 ? Math.max(natural, minHeight) : natural
473    result.push({ height, alt, source: svgDocument(width, height, frame(width, height, variant) + (result.length === 0 ? decoration : '') + body.join('')) })
474    pending = []
475    used = 2 * PAD
476  }
477  for (const section of sections) {
478    if (pending.length > 0 && used + section.height > MAX_HEIGHT) {
479      close()
480    }
481    pending.push(section)
482    used += section.height
483  }
484  close()
485
486  return result
487}
488
489export const GLOW =
490  '<defs><linearGradient id="glow" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#d97757" stop-opacity="0.14"/><stop offset="0.55" stop-color="#d97757" stop-opacity="0"/></linearGradient></defs>'
491
492// The card heading a pane: an eyebrow, a count at the right, a title of up
493// to two lines and, given segments, a bar with one segment per step.
494// `reserve` keeps the title clear of native buttons laid over the right side;
495// a card without a bar is too low for them beside the count at its top right,
496// so the count then goes beneath the title.
497export function headerCard(card: { width: number; eyebrow: string; title: string; counter: string; segments?: readonly Segment[]; reserve?: number }): Card {
498  const { width } = card
499  const segments = card.segments ?? []
500  const inner = width - 2 * PAD
501  const reserve = card.reserve ?? 0
502  const isCountBelow = reserve > 0 && segments.length === 0
503  const counterWidth = isCountBelow ? 0 : textWidth(card.counter, 12)
504  const eyebrow = fitText(card.eyebrow, inner - counterWidth - 16 - (isCountBelow ? reserve : 0), EYEBROW, true)
505  const title = textSection(parseInline(card.title).map(i => ({ ...i, isBold: true })), PAD, inner - reserve, { size: 17, maxLines: 2 })
506  const titleTop = PAD + EYEBROW + 8
507  const countY = titleTop + title.height + 14
508  const barY = titleTop + title.height + 12
509  const height = isCountBelow ? countY + PAD - 2 : segments.length === 0 ? titleTop + title.height + PAD - 4 : barY + 6 + PAD
510  const count = Math.max(1, segments.length)
511  const segmentWidth = (inner - 4 * (count - 1)) / count
512  const bars = segments.map(
513    (segment, index) => `<rect class="${segment}" x="${(PAD + index * (segmentWidth + 4)).toFixed(1)}" y="${barY}" width="${segmentWidth.toFixed(1)}" height="6" rx="3"/>`,
514  )
515  const body = [
516    frame(width, height, 'card'),
517    GLOW,
518    `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${RADIUS}" fill="url(#glow)"/>`,
519    eyebrowSvg(eyebrow, PAD, PAD + EYEBROW),
520    isCountBelow
521      ? `<text class="muted" x="${PAD}" y="${countY}" font-size="12.5">${escape(fitText(card.counter, inner - reserve, 12.5))}</text>`
522      : `<text class="muted" x="${width - PAD}" y="${PAD + EYEBROW}" font-size="12" text-anchor="end">${escape(card.counter)}</text>`,
523    title.draw(titleTop),
524    ...bars,
525  ]
526
527  return { height, alt: `${card.eyebrow}: ${plainOf(card.title)}, ${card.counter}`, source: svgDocument(width, height, body.join('')) }
528}
529
530export function noticeCard(width: number, variant: 'card-warning' | 'card-error', title: string, text: string, reserve = 0): Card[] {
531  const inner = width - 2 * PAD - reserve
532  const body = textSection([{ text }], PAD, inner, { tone: 'muted' })
533  const sections = [textSection([{ text: title, isBold: true }], PAD, inner, { size: 15 }), { height: body.height + 6, draw: (y: number) => body.draw(y + 6) }]
534
535  return cards(width, variant, sections, `${title} ${text}`, '', reserve > 0 ? 72 : 0)
536}
537
538const FIELD_HEIGHT = 72
539
540// A card holding a native text field: its label and a hint in a column at the
541// left, the rest left free for the field laid over it. `labelWidth` is that
542// column's width; the field starts past it.
543export function fieldCard(width: number, label: string, hint: string, labelWidth: number): Card {
544  const maxWidth = labelWidth - PAD - 12
545  const hints = wrapRuns([{ text: hint }], maxWidth, 12).slice(0, 2)
546  const top = FIELD_HEIGHT / 2 - (hints.length * 16 + EYEBROW + 6) / 2 + EYEBROW
547  const body = [
548    frame(width, FIELD_HEIGHT, 'card'),
549    eyebrowSvg(fitText(label, maxWidth, EYEBROW, true), PAD, top),
550    ...hints.map((line, index) => lineSvg(line, PAD, top + 18 + index * 16, 12, 'muted')),
551    `<rect class="rule" x="${labelWidth - 6}" y="16" width="1" height="${FIELD_HEIGHT - 32}"/>`,
552  ]
553
554  return { height: FIELD_HEIGHT, alt: `${label}: ${hint}`, source: svgDocument(width, FIELD_HEIGHT, body.join('')) }
555}
556
557// Below this width a card leaves too little room beside native controls, so
558// they go beneath it instead of over it.
559export const ROOMY_WIDTH = 420
560
561// A slim card for a row of native buttons laid over it; given a label, that
562// heads the bar at its left, as a section title with its actions beside it.
563export function barCard(width: number, alt: string, label = '', variant: Variant = 'card', reserve = 0): Card {
564  const height = 56
565  const text = label.length === 0 ? '' : eyebrowSvg(fitText(label, width - 2 * PAD - reserve - 12, EYEBROW, true), PAD, height / 2 + EYEBROW / 2 - 1)
566
567  return { height, alt, source: svgDocument(width, height, frame(width, height, variant) + text) }
568}
569
570// A diff or a list of changes in the code font: added lines green, removed
571// ones red, hunk heads in the accent color. Long lines are cut, not wrapped,
572// so the columns stay aligned.
573export function diffCards(width: number, text: string, alt: string): Card[] {
574  const size = 12
575  const lineHeight = 17
576  const maxWidth = width - 2 * PAD
577  const rows = text.replace(/\s+$/u, '').split('\n')
578  const toneOf = (row: string) =>
579    /^\+(?!\+\+)/u.test(row) ? 'success' : /^-(?!--)/u.test(row) ? 'error' : /^@@/u.test(row) ? 'accent' : /^(diff |index |\+\+\+|---)/u.test(row) ? 'muted' : 't'
580  const sections: Section[] = rows.map(row => ({
581    height: lineHeight,
582    draw: y =>
583      `<text class="${toneOf(row)} mono" x="${PAD}" y="${(y + lineHeight * 0.75).toFixed(1)}" font-size="${size}">${escape(fitMono(row, maxWidth, size))}</text>`,
584  }))
585
586  return cards(width, 'card', sections, alt)
587}
588
589function fitMono(text: string, maxWidth: number, size: number): string {
590  const chars = Array.from(text.replace(/\t/gu, '  '))
591  const fits = Math.floor(maxWidth / (size * 0.62))
592
593  return chars.length <= fits ? chars.join('') : `${chars.slice(0, Math.max(1, fits - 1)).join('')}…`
594}
595
596// An eyebrow line heading a card, with an optional second one at its right.
597export function eyebrowSection(left: string, right: string | null, rightTone: string, width: number): Section {
598  return {
599    height: EYEBROW + 10,
600    draw: y => eyebrowSvg(left, PAD, y + EYEBROW) + (right === null ? '' : eyebrowSvg(right, width - PAD, y + EYEBROW, rightTone, 'end')),
601  }
602}
603
types/index.d.ts 33 lines
1export type HubAttention = 'none' | 'info' | 'warning'
2
3export type HubAction = {
4  key: string
5  label: string
6}
7
8export type HubEntry = {
9  id: string
10  icon: string
11  title: string
12  status: string
13  attention: HubAttention
14  actions: HubAction[]
15}
16
17export type Hub = {
18  set: (entry: HubEntry) => Promise<void>
19  remove: (input: { id: string }) => Promise<void>
20}
21
22declare module 'claude-code' {
23  interface EngineInterface {
24    hub: Hub
25  }
26
27  interface PluginState {
28    'mod-hub': {
29      entries: HubEntry[]
30    }
31  }
32}
33