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

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.
| When | What 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 attention | a count on the button, 🧰 2, yellow when something is urgent, such as the cache about to expire |
Click on 🧰, or /hub | the Mods pane: one card per mod with its icon, its status and its actions, the mods that need a look first |
| Click on an action | the mod does it, usually opening its own pane, and the hub closes |
| Mod | Status | Actions |
|---|---|---|
idea-shell | ideas for this folder | Open |
service-radar | services running | Open |
grill-panel | answered questions of the open round | Continue, or Latest round |
cache-watch | how long the cache stays warm, the context size | Compact now between turns, when compacting pays off |
session-relay | channel and role, waiting tasks or answers | Open |
concurrency-guard | subagents and monitors running | — |
config-snapshots | whether the config changed since the last snapshot | Open |
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.
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.
Remove the mod from CLAUDE_CODE_PLUGIN_DIRS. The other mods draw their own footer labels again.
hooks/register.tsx 167 lines1import { 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}
167hooks/entries.ts 35 lines1import 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}
35hooks/look.ts 36 lines1import 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}
36hooks/svg.ts 603 lines1// 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,"Segoe UI",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, '&').replace(/</gu, '<').replace(/>/gu, '>').replace(/"/gu, '"')
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}
603types/index.d.ts 33 lines1export 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