SLOPSHOPPER

launchpad

KOZMOS Launchpad: a pane of big launch buttons. It learns your most-used slash commands as you run them, keeps pinned favorites and saved prompts, and runs a…

newpanecommand
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/launchpad
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · launchpad
│ ┃ KOZMOS · Launchpad ✕ › fix the failing auth test and add an audit log call │ ┃ ➚ LAUNCHPAD 0 runs learned · 0 saved │ ┃ ⏺ Read(src/auth.ts) │ ┃ ★ PINNED press ☆ on a command, or /launchp… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⚡ FREQUENT run a few slash commands; they … ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ✎ SAVED /launchpad save <prompt text> ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /launchpad │ ⎿ launchpad: Launchpad open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Launchpad
➚ LAUNCHPAD 0 runs learned · 0 saved ★ PINNED press ☆ on a command, or /launchpad pin <cmd> ⚡ FREQUENT run a few slash commands; they land here ✎ SAVED /launchpad save <prompt text>
README

KOZMOS Launchpad

A pane of big launch buttons for the things you do every day.

  • Frequent: Launchpad watches the slash commands you run from the prompt box and ranks them by how often (counts are kept between sessions). Commands that no longer exist drop out.
  • Pinned: press ☆ on any command (or /launchpad pin <cmd>) to keep it at the top; ★ (or /launchpad unpin <cmd>) lets it go.
  • Saved: /launchpad save <text> stores a prompt; pressing it fills the prompt box so you can edit before sending. /launchpad rm <n> removes one.

Pressing a command runs it at once. On the desktop each launcher is an SVG tile (initial disc, name, description, use count and a small usage bar) with its Run and pin buttons beside it; on the terminal each is a [ ▶ /name ] button with a colored usage bar, count and when you last ran it.

/launchpad toggles the pane, /launchpad list prints everything, /launchpad forget clears the counts. autoOpen (default off) opens the pane at start.

Türkçe

Her gün yaptığınız işler için büyük başlatma düğmeleri olan bir panel.

  • Sık kullanılanlar: Launchpad istem kutusundan çalıştırdığınız eğik çizgi komutlarını izler ve ne sıklıkla kullandığınıza göre sıralar (sayılar oturumlar arasında saklanır). Artık var olmayan komutlar listeden düşer.
  • Sabitlenenler: bir komutta ☆'e basın (ya da /launchpad pin <komut>), en üstte kalsın; ★ (ya da /launchpad unpin <komut>) sabitlemeyi kaldırır.
  • Kayıtlı istemler: /launchpad save <metin> bir istemi saklar; basınca istem kutusunu doldurur, göndermeden önce düzenleyebilirsiniz. /launchpad rm <n> birini siler.

Komut düğmesine basmak onu hemen çalıştırır. Masaüstünde her başlatıcı, yanında Run ve sabitleme düğmeleri olan bir SVG kartıdır; terminalde renkli kullanım çubuğu, sayı ve son kullanım zamanıyla bir [ ▶ /ad ] düğmesidir.

/launchpad paneli açıp kapatır, /launchpad list her şeyi yazar, /launchpad forget sayıları sıfırlar. autoOpen (varsayılan kapalı) paneli açılışta açar.

Source 4 files
hooks/register.tsx 276 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LaunchpadData, PadCount } from '../types'
5import { KZ, bar, clip, pxOf } from './lib/kz.ts'
6import {
7  MAX_SAVED, agoText, accentOf, bannerSvg, bump, commandCardSvg, errorText, fillNote, frequentTiles, listText, parseArgs, pinnedTiles,
8  promptCardSvg, sectionSvg,
9} from './pad.ts'
10import type { Tile } from './pad.ts'
11
12const PANE = 'kz-launchpad'
13const TITLE = 'KOZMOS · Launchpad'
14
15const padAtom = atom({ plugin: 'launchpad', key: 'pad' } as const, { counts: {}, pinned: [], saved: [], known: {}, hasList: false })
16
17async function persist($: EngineInterface): Promise<void> {
18  const d = await read($, padAtom)
19  try {
20    await $.store.set('counts', d.counts)
21    await $.store.set('pinned', d.pinned)
22    await $.store.set('saved', d.saved)
23  } catch {
24    // Kept for this session only.
25  }
26}
27
28async function load($: EngineInterface): Promise<void> {
29  try {
30    const counts = await $.store.get('counts')
31    const pinned = await $.store.get('pinned')
32    const saved = await $.store.get('saved')
33    await update($, padAtom, prev => ({
34      ...prev,
35      counts: isCounts(counts) ? counts : prev.counts,
36      pinned: isStrings(pinned) ? pinned : prev.pinned,
37      saved: isStrings(saved) ? saved : prev.saved,
38    }))
39  } catch {
40    // Fresh store.
41  }
42}
43
44async function refreshKnown($: EngineInterface): Promise<void> {
45  try {
46    const list = await $.command.list()
47    const known: Record<string, string> = {}
48    for (const c of list) known[c.name] = c.description
49    await update($, padAtom, prev => ({ ...prev, known, hasList: true }))
50  } catch {
51    // Keep what was known.
52  }
53}
54
55async function record($: EngineInterface, name: string): Promise<void> {
56  const at = await $.clock.now()
57  await update($, padAtom, prev => ({ ...prev, counts: bump(prev.counts, name, at) }))
58  await persist($)
59}
60
61async function launch($: EngineInterface, name: string): Promise<void> {
62  await record($, name)
63  try {
64    const { text } = await $.command.run({ command: name })
65    await update($, padAtom, prev => ({ ...prev, lastRun: { label: `/${name}`, text: clip(text ?? 'done', 120) || 'done' } }))
66  } catch (err) {
67    await update($, padAtom, prev => ({ ...prev, lastRun: { label: `/${name}`, text: errorText(err) } }))
68  }
69}
70
71async function fill($: EngineInterface, text: string): Promise<void> {
72  let note: string
73  try {
74    note = fillNote(await $.prompt.fill({ text }))
75  } catch (err) {
76    note = errorText(err)
77  }
78  await update($, padAtom, prev => ({ ...prev, lastRun: { label: 'prompt', text: note } }))
79}
80
81async function setPinned($: EngineInterface, name: string, isPinned: boolean): Promise<void> {
82  await update($, padAtom, prev => ({
83    ...prev,
84    pinned: isPinned ? (prev.pinned.includes(name) ? prev.pinned : [...prev.pinned, name]) : prev.pinned.filter(n => n !== name),
85  }))
86  await persist($)
87}
88
89async function removeSaved($: EngineInterface, index: number): Promise<string | undefined> {
90  let removed: string | undefined
91  await update($, padAtom, prev => {
92    removed = prev.saved[index]
93    return removed === undefined ? prev : { ...prev, saved: prev.saved.filter((_, i) => i !== index) }
94  })
95  if (removed !== undefined) await persist($)
96  return removed
97}
98
99async function toggle($: EngineInterface): Promise<boolean> {
100  if ((await $.ui.panes()).some(p => p.id === PANE)) {
101    await $.ui.close({ id: PANE })
102    return false
103  }
104  await refreshKnown($)
105  await $.ui.open({ id: PANE, title: TITLE })
106  return true
107}
108
109function isStrings(v: unknown): v is string[] {
110  return Array.isArray(v) && v.every(x => typeof x === 'string')
111}
112
113function isCounts(v: unknown): v is Record<string, PadCount> {
114  if (typeof v !== 'object' || v === null || Array.isArray(v)) return false
115  return Object.values(v).every(x => typeof x === 'object' && x !== null && typeof (x as PadCount).n === 'number' && typeof (x as PadCount).at === 'number')
116}
117
118export const register: Register = (on, options) => {
119  on('session.start', async ($, e, next) => {
120    const started = await next(e)
121    await $.command.register({
122      name: 'launchpad',
123      description: 'KOZMOS: toggle the Launchpad of your most-used commands and saved prompts (save <text>, rm <n>, pin <cmd>, unpin <cmd>, list)',
124      argumentHint: '[save <text> | rm <n> | pin <cmd> | unpin <cmd> | list]',
125      immediate: true,
126    })
127    await load($)
128    await refreshKnown($)
129    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE })
130    return started
131  })
132
133  on('command.run', { command: 'launchpad' }, async ($, e) => {
134    const action = parseArgs(e.args)
135    switch (action.kind) {
136      case 'toggle':
137        return { text: (await toggle($)) ? 'Launchpad open.' : 'Launchpad closed.' }
138      case 'list':
139        await refreshKnown($)
140        return { text: listText(await read($, padAtom)) }
141      case 'save': {
142        let n = 0
143        await update($, padAtom, prev => {
144          const saved = [...prev.saved.filter(s => s !== action.text), action.text].slice(-MAX_SAVED)
145          n = saved.length
146          return { ...prev, saved }
147        })
148        await persist($)
149        return { text: `Saved as prompt ${n}.` }
150      }
151      case 'rm': {
152        const removed = await removeSaved($, action.index)
153        return { text: removed === undefined ? `No saved prompt ${action.index + 1}.` : `Removed prompt ${action.index + 1}: ${clip(removed, 60)}` }
154      }
155      case 'pin':
156        await setPinned($, action.name, true)
157        return { text: `Pinned /${action.name}.` }
158      case 'unpin':
159        await setPinned($, action.name, false)
160        return { text: `Unpinned /${action.name}.` }
161      case 'forget':
162        await update($, padAtom, prev => ({ ...prev, counts: {} }))
163        await persist($)
164        return { text: 'Launchpad forgot the command counts (pins and saved prompts stay).' }
165      case 'help':
166        return { text: action.reason }
167    }
168  })
169
170  // Learn: every slash command the person runs from the prompt box.
171  on('command.run', async ($, e, next) => {
172    if (e.origin.kind === 'composer' && e.command !== 'launchpad') await record($, e.command).catch(() => undefined)
173    return next(e)
174  }).catch(($, e, next) => next(e))
175
176  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
177    const d: LaunchpadData = await read($, padAtom)
178    const now = await $.clock.now()
179    const pinned = pinnedTiles(d)
180    const frequent = frequentTiles(d)
181    const maxCount = Math.max(1, ...[...pinned, ...frequent].map(t => t.count))
182    const runs = Object.values(d.counts).reduce((s, c) => s + c.n, 0)
183    const ui = $.ui.resolve(e)
184    const { Box, Button, Text } = ui
185    const footer = d.lastRun ? (
186      <Text key="last" dimColor wrap="truncate-end">↳ {d.lastRun.label}: {d.lastRun.text}</Text>
187    ) : null
188
189    if ('Svg' in ui && e.surface !== 'terminal') {
190      const { Svg } = ui
191      const W = pxOf(e.props.bodyColumns, 44)
192      const cardW = Math.max(160, W - 96)
193      const commandRow = (t: Tile, isPinned: boolean) => (
194        <Box key={`${isPinned ? 'p' : 'f'}-${t.name}`} flexDirection="row" alignItems="center" gap={1}>
195          <Svg source={commandCardSvg(t, cardW, now, maxCount, isPinned)} alt={`/${t.name}: ${t.description || agoText(t.at, now)}${t.count ? `, run ${t.count} times` : ''}`} width={cardW} height={56} />
196          <Box flexDirection="column">
197            <Button key={`run-${t.name}`} label="Run" variant={isPinned ? 'primary' : undefined} onPress={() => void launch($, t.name)} />
198            <Button key={`${isPinned ? 'unpin' : 'pin'}-${t.name}`} label={isPinned ? '★' : '☆'} plain dimColor onPress={() => void setPinned($, t.name, !isPinned)} />
199          </Box>
200        </Box>
201      )
202      return (
203        <Box flexDirection="column" gap={1}>
204          <Svg key="banner" source={bannerSvg(W, runs, Object.keys(d.counts).length, d.saved.length)} alt={`Launchpad: ${runs} runs learned, ${d.saved.length} saved prompts`} width={W} height={64} />
205          <Svg key="h-pinned" source={sectionSvg('Pinned', pinned.length ? `${pinned.length}` : 'star a command to keep it here', KZ.yellow, W)} alt="Pinned" width={W} height={26} />
206          {pinned.map(t => commandRow(t, true))}
207          <Svg key="h-frequent" source={sectionSvg('Frequent', frequent.length ? 'learned from what you run' : 'run a few slash commands', KZ.violet, W)} alt="Frequent" width={W} height={26} />
208          {frequent.map(t => commandRow(t, false))}
209          <Svg key="h-saved" source={sectionSvg('Saved', d.saved.length ? 'click to fill the prompt box' : '/launchpad save <text>', KZ.cyan, W)} alt="Saved prompts" width={W} height={26} />
210          {d.saved.map((s, i) => (
211            <Box key={`s-${i}`} flexDirection="row" alignItems="center" gap={1}>
212              <Svg source={promptCardSvg(s, i, cardW)} alt={`Saved prompt ${i + 1}: ${clip(s, 120)}`} width={cardW} height={56} />
213              <Box flexDirection="column">
214                <Button key={`fill-${i}`} label="Fill" onPress={() => void fill($, s)} />
215                <Button key={`rm-${i}`} label="✕" plain dimColor onPress={() => void removeSaved($, i)} />
216              </Box>
217            </Box>
218          ))}
219          {footer}
220        </Box>
221      )
222    }
223
224    const cols = Math.max(30, e.props.bodyColumns || 40)
225    const labelW = Math.min(24, Math.max(12, ...[...pinned, ...frequent].map(t => t.name.length + 4)))
226    const barW = Math.max(4, Math.min(12, cols - labelW - 22))
227    const commandLine = (t: Tile, isPinned: boolean) => (
228      <Box key={`${isPinned ? 'p' : 'f'}-${t.name}`} flexDirection="row" justifyContent="space-between">
229        <Box flexDirection="row">
230          <Button key={`run-${t.name}`} label={`▶ /${t.name}`.padEnd(labelW)} variant={isPinned ? 'primary' : 'secondary'} onPress={() => void launch($, t.name)} />
231          <Text wrap="truncate-end">
232            <Text color={isPinned ? KZ.yellow : accentOf(t.name)}> {bar(t.count / maxCount, barW, '·')}</Text>
233            <Text dimColor> ×{t.count} · {agoText(t.at, now)}</Text>
234          </Text>
235        </Box>
236        <Button key={`${isPinned ? 'unpin' : 'pin'}-${t.name}`} label={isPinned ? '★' : '☆'} plain dimColor onPress={() => void setPinned($, t.name, !isPinned)} />
237      </Box>
238    )
239    const head = (key: string, glyph: string, label: string, color: string, sub: string) => (
240      <Text key={key} wrap="truncate-end">
241        <Text color={color} bold>{glyph} {label}</Text>
242        <Text dimColor>  {sub}</Text>
243      </Text>
244    )
245    return (
246      <Box flexDirection="column">
247        <Text bold>
248          <Text color={KZ.violet}>➚ LAUNCHPAD</Text>
249          <Text dimColor>  {runs} runs learned · {d.saved.length} saved</Text>
250        </Text>
251        <Box flexDirection="column" marginTop={1}>
252          {head('h-pinned', '★', 'PINNED', KZ.yellow, pinned.length ? '' : 'press ☆ on a command, or /launchpad pin <cmd>')}
253          {pinned.map(t => commandLine(t, true))}
254        </Box>
255        <Box flexDirection="column" marginTop={1}>
256          {head('h-frequent', '⚡', 'FREQUENT', KZ.violet, frequent.length ? '' : 'run a few slash commands; they land here')}
257          {frequent.map(t => commandLine(t, false))}
258        </Box>
259        <Box flexDirection="column" marginTop={1}>
260          {head('h-saved', '✎', 'SAVED', KZ.cyan, d.saved.length ? 'press to fill the prompt box' : '/launchpad save <prompt text>')}
261          {d.saved.map((s, i) => (
262            <Box key={`s-${i}`} flexDirection="row" justifyContent="space-between">
263              <Box flexDirection="row">
264                <Button key={`fill-${i}`} label={`✎ ${i + 1}`} variant="secondary" onPress={() => void fill($, s)} />
265                <Text wrap="truncate-end"> {clip(s, Math.max(10, cols - 14))}</Text>
266              </Box>
267              <Button key={`rm-${i}`} label="✕" plain dimColor onPress={() => void removeSaved($, i)} />
268            </Box>
269          ))}
270        </Box>
271        {footer}
272      </Box>
273    )
274  })
275}
276
hooks/lib/kz.ts 510 lines
1// GENERATED by scripts/sync-shared.mjs from shared/kz.ts. Do not edit here.
2// KOZMOS shared kit. Source of truth: shared/kz.ts at the bundle root.
3// `node scripts/sync-shared.mjs` copies it into every mod as hooks/lib/kz.ts,
4// because a hooks module may only import files inside its own plugin.
5// Edit the root copy, never a mod's copy.
6
7// ---------------------------------------------------------------------------
8// Palette: one neon family across every KOZMOS mod.
9
10export const KZ = {
11  violet: '#a78bfa',
12  magenta: '#f472b6',
13  cyan: '#22d3ee',
14  teal: '#2dd4bf',
15  green: '#4ade80',
16  lime: '#a3e635',
17  yellow: '#facc15',
18  amber: '#fb923c',
19  red: '#f87171',
20  blue: '#60a5fa',
21  clay: '#d97757',
22  ink: '#1f1e1d',
23  mist: '#9ca3af',
24} as const
25
26/** Tool families, colored the same in every mod. */
27export function toolColor(tool: string): string {
28  const t = String(tool)
29  if (t === 'Bash' || t === 'PowerShell') return KZ.green
30  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit' || t === 'MultiEdit') return KZ.yellow
31  if (t === 'Read' || t === 'Glob' || t === 'Grep' || t === 'LSP') return KZ.blue
32  if (t === 'Agent' || t === 'Task' || t === 'Workflow') return KZ.violet
33  if (t.startsWith('Web')) return KZ.cyan
34  if (t.startsWith('Todo') || t.startsWith('Task')) return KZ.teal
35  if (t.startsWith('mcp__')) return KZ.magenta
36  return KZ.mist
37}
38
39/** A short glyph per tool family. */
40export function toolGlyph(tool: string): string {
41  const t = String(tool)
42  if (t === 'Bash' || t === 'PowerShell') return '$'
43  if (t === 'Edit' || t === 'Write' || t === 'NotebookEdit') return '✎'
44  if (t === 'Read') return '◉'
45  if (t === 'Glob' || t === 'Grep') return '⌕'
46  if (t === 'Agent') return '◈'
47  if (t.startsWith('Web')) return '◍'
48  if (t.startsWith('Todo') || t.startsWith('Task')) return '☑'
49  if (t.startsWith('mcp__')) return '⬡'
50  return '•'
51}
52
53// ---------------------------------------------------------------------------
54// Colors.
55
56export function hexToRgb(hex: string): [number, number, number] {
57  const h = hex.replace('#', '')
58  const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16)
59  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
60}
61
62export function rgbToHex(r: number, g: number, b: number): string {
63  const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')
64  return `#${c(r)}${c(g)}${c(b)}`
65}
66
67export function mix(a: string, b: string, t: number): string {
68  const [r1, g1, b1] = hexToRgb(a)
69  const [r2, g2, b2] = hexToRgb(b)
70  const k = clamp01(t)
71  return rgbToHex(r1 + (r2 - r1) * k, g1 + (g2 - g1) * k, b1 + (b2 - b1) * k)
72}
73
74/** Green at 0, yellow at 0.6, red at 1: the heat of a gauge. */
75export function heat(t: number): string {
76  const k = clamp01(t)
77  return k < 0.6 ? mix(KZ.green, KZ.yellow, k / 0.6) : mix(KZ.yellow, KZ.red, (k - 0.6) / 0.4)
78}
79
80/** A smooth rainbow for hues 0..1 (for auroras, plasma and the like). */
81export function hue(h: number, s = 0.75, l = 0.6): string {
82  const k = ((h % 1) + 1) % 1
83  const a = s * Math.min(l, 1 - l)
84  const f = (n: number) => {
85    const x = (n + k * 12) % 12
86    return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1))
87  }
88  return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255)
89}
90
91export function hexToInt(hex: string): number {
92  const [r, g, b] = hexToRgb(hex)
93  return (r << 16) | (g << 8) | b
94}
95
96// ---------------------------------------------------------------------------
97// Numbers and text.
98
99export const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0)
100
101export function fmtTokens(n: number): string {
102  if (!Number.isFinite(n)) return '—'
103  // Each unit starts where the one below would round up to 1000 of itself.
104  if (n >= 999.5e6) return `${(n / 1e9).toFixed(1)}B`
105  if (n >= 999_500) return `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M`
106  if (n >= 999.5) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`
107  return `${Math.round(n)}`
108}
109
110export function fmtUsd(usd: number): string {
111  if (!Number.isFinite(usd)) return '$—'
112  if (usd < 0.01 && usd > 0) return '<$0.01'
113  return usd < 100 ? `$${usd.toFixed(2)}` : `$${Math.round(usd)}`
114}
115
116/** 0:42, 3:07, 1:02:33. */
117export function fmtClock(ms: number): string {
118  const s = Math.max(0, Math.round(ms / 1000))
119  const h = Math.floor(s / 3600)
120  const m = Math.floor((s % 3600) / 60)
121  const ss = String(s % 60).padStart(2, '0')
122  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
123}
124
125/** 42s, 7m, 2h41m, 3d4h: compact durations for countdowns. */
126export function fmtSpan(ms: number): string {
127  const s = Math.max(0, Math.round(ms / 1000))
128  if (s < 60) return `${s}s`
129  const m = Math.floor(s / 60)
130  if (m < 60) return `${m}m`
131  const h = Math.floor(m / 60)
132  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
133  return `${Math.floor(h / 24)}d${h % 24}h`
134}
135
136export function fmtPct(p: number | undefined): string {
137  return p === undefined || !Number.isFinite(p) ? '—' : `${Math.round(p)}%`
138}
139
140export function clip(s: string, max: number): string {
141  const one = s.replace(/\s+/g, ' ').trim()
142  return one.length > max ? one.slice(0, Math.max(1, max - 1)) + '…' : one
143}
144
145export function padEnd(s: string, n: number): string {
146  return s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length)
147}
148
149export function padStart(s: string, n: number): string {
150  return s.length >= n ? s.slice(s.length - n) : ' '.repeat(n - s.length) + s
151}
152
153/** `claude-opus-5-5[1m]` → `Opus 5.5`. */
154export function modelName(id: string | undefined): string {
155  if (!id) return '—'
156  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
157  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '')
158  const fam = m[1]!
159  return `${fam.charAt(0).toUpperCase()}${fam.slice(1).toLowerCase()} ${m[2]}${m[3] ? '.' + m[3] : ''}`
160}
161
162/** Last path segment, either slash. */
163export function baseName(p: string): string {
164  const parts = p.split(/[\\/]/).filter(Boolean)
165  return parts[parts.length - 1] ?? p
166}
167
168/** A stable small hash for seeding. */
169export function hash(s: string): number {
170  let h = 2166136261
171  for (let i = 0; i < s.length; i++) {
172    h ^= s.charCodeAt(i)
173    h = Math.imul(h, 16777619)
174  }
175  return h >>> 0
176}
177
178/** Deterministic 0..1 noise from two ints. */
179export function noise(x: number, y: number): number {
180  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
181  return s - Math.floor(s)
182}
183
184/** A seeded PRNG (mulberry32). */
185export function rng(seed: number): () => number {
186  let a = seed >>> 0
187  return () => {
188    a = (a + 0x6d2b79f5) >>> 0
189    let t = a
190    t = Math.imul(t ^ (t >>> 15), t | 1)
191    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
192    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
193  }
194}
195
196// ---------------------------------------------------------------------------
197// Text gauges for the terminal.
198
199const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
200
201/** A smooth bar with eighth blocks: `█████▍    `. */
202export function bar(ratio: number, width: number, empty = '░'): string {
203  const w = Math.max(1, Math.floor(width))
204  const exact = clamp01(ratio) * w
205  const full = Math.floor(exact)
206  const part = EIGHTHS[Math.floor((exact - full) * 8)] ?? ''
207  const used = full + (part ? 1 : 0)
208  return '█'.repeat(full) + part + empty.repeat(Math.max(0, w - used))
209}
210
211/** A segmented gauge: `▰▰▰▱▱▱`. */
212export function pips(ratio: number, width: number, on = '▰', off = '▱'): string {
213  const w = Math.max(1, Math.floor(width))
214  const n = Math.round(clamp01(ratio) * w)
215  return on.repeat(n) + off.repeat(w - n)
216}
217
218const SPARK = '▁▂▃▄▅▆▇█'
219
220/** A sparkline of the last `width` values, scaled to their own max (or `max`). */
221export function sparkline(values: readonly number[], width: number, max?: number): string {
222  const tail = values.slice(-Math.max(1, width))
223  const top = max ?? Math.max(1e-9, ...tail)
224  const line = tail.map(v => SPARK[Math.min(7, Math.max(0, Math.round((v / top) * 7)))] ?? '▁').join('')
225  return line.padStart(width, ' ')
226}
227
228/** Braille line graph, 2 samples per cell, `rows` cells tall. */
229export function brailleGraph(values: readonly number[], width: number, rows: number, max?: number): string[] {
230  const samples = values.slice(-(width * 2))
231  const top = max ?? Math.max(1e-9, ...samples)
232  const dotsTall = rows * 4
233  const grid: number[][] = Array.from({ length: rows }, () => Array.from({ length: width }, () => 0))
234  const offset = width * 2 - samples.length
235  // Dot bits per column, from the bottom row of a cell up.
236  const LEFT = [0x40, 0x04, 0x02, 0x01]
237  const RIGHT = [0x80, 0x20, 0x10, 0x08]
238  samples.forEach((v, i) => {
239    const x = offset + i
240    const col = Math.floor(x / 2)
241    const isRight = x % 2 === 1
242    const h = Math.round(clamp01(v / top) * (dotsTall - 1))
243    for (let d = 0; d <= h; d++) {
244      const row = rows - 1 - Math.floor(d / 4)
245      const bits = isRight ? RIGHT : LEFT
246      const cellRow = grid[row]
247      if (cellRow && col >= 0) cellRow[col] = (cellRow[col] ?? 0) | bits[d % 4]!
248    }
249  })
250  return grid.map(r => r.map(b => String.fromCharCode(0x2800 + b)).join(''))
251}
252
253// ---------------------------------------------------------------------------
254// Raster: a grid of colored cells, packed as the engine wants it.
255
256const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
257
258export function toBase64(bytes: Uint8Array): string {
259  let out = ''
260  let i = 0
261  for (; i + 2 < bytes.length; i += 3) {
262    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
263    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
264  }
265  const rest = bytes.length - i
266  if (rest === 1) {
267    const n = bytes[i]! << 16
268    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
269  } else if (rest === 2) {
270    const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
271    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
272  }
273  return out
274}
275
276/** The terminal's own default color, for a cell's foreground or background. */
277export const DEFAULT_COLOR = 0x01000000
278
279export class Canvas {
280  readonly cols: number
281  readonly rows: number
282  private readonly words: Uint32Array
283
284  constructor(cols: number, rows: number) {
285    this.cols = Math.max(1, Math.min(512, Math.floor(cols)))
286    this.rows = Math.max(1, Math.min(256, Math.floor(rows)))
287    this.words = new Uint32Array(this.cols * this.rows * 3)
288    this.clear()
289  }
290
291  clear(bg: number = DEFAULT_COLOR): void {
292    for (let i = 0; i < this.cols * this.rows; i++) {
293      this.words[i * 3] = 0x20
294      this.words[i * 3 + 1] = DEFAULT_COLOR
295      this.words[i * 3 + 2] = bg
296    }
297  }
298
299  /** Puts one width-1 character; colors are '#rrggbb' or a packed int. */
300  set(x: number, y: number, ch: string, fg?: string | number, bg?: string | number): void {
301    const cx = Math.floor(x)
302    const cy = Math.floor(y)
303    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
304    const i = (cy * this.cols + cx) * 3
305    const code = ch.codePointAt(0) ?? 0x20
306    this.words[i] = code > 0xffff || code < 0x20 ? 0x20 : code
307    if (fg !== undefined) this.words[i + 1] = typeof fg === 'number' ? fg : hexToInt(fg)
308    if (bg !== undefined) this.words[i + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
309  }
310
311  /** Writes a string left to right, clipped to the canvas. */
312  text(x: number, y: number, s: string, fg?: string | number, bg?: string | number): void {
313    let cx = x
314    for (const ch of s) {
315      this.set(cx, y, ch, fg, bg)
316      cx++
317    }
318  }
319
320  /** Paints a cell's background only, keeping its character. */
321  paint(x: number, y: number, bg: string | number): void {
322    const cx = Math.floor(x)
323    const cy = Math.floor(y)
324    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
325    this.words[(cy * this.cols + cx) * 3 + 2] = typeof bg === 'number' ? bg : hexToInt(bg)
326  }
327
328  /** Two vertical pixels per cell with the upper-half block: `py` is in half-cells. */
329  pixel(x: number, py: number, color: string): void {
330    const cx = Math.floor(x)
331    const cy = Math.floor(py / 2)
332    if (cx < 0 || cy < 0 || cx >= this.cols || cy >= this.rows) return
333    const i = (cy * this.cols + cx) * 3
334    const c = hexToInt(color)
335    const isTop = Math.floor(py) % 2 === 0
336    if (this.words[i] !== 0x2580) {
337      this.words[i] = 0x2580
338      this.words[i + 1] = DEFAULT_COLOR
339      this.words[i + 2] = DEFAULT_COLOR
340    }
341    if (isTop) this.words[i + 1] = c
342    else this.words[i + 2] = c
343  }
344
345  /** The `cells` prop of a Raster. */
346  encode(): string {
347    return toBase64(new Uint8Array(this.words.buffer))
348  }
349}
350
351// ---------------------------------------------------------------------------
352// SVG for the desktop: one drawing per row (the desktop wraps siblings).
353
354export const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif"
355export const MONO = "ui-monospace,'Cascadia Code','SF Mono',Consolas,monospace"
356
357const XML_ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
358
359export function xml(s: string): string {
360  return s.replace(/[&<>"']/g, c => XML_ESC[c]!)
361}
362
363/** Theme-aware classes every KOZMOS drawing shares: t text, s secondary, m muted, k track, p panel. */
364export const SVG_BASE_CSS = `
365.t{fill:#1f1f1f}.s{fill:#5f5f5c}.m{fill:#8e8e8a}.k{fill:#e7e5e0}.p{fill:#f5f4f1}.ln{stroke:#e1dfda}
366@media (prefers-color-scheme: dark){.t{fill:#ededed}.s{fill:#b4b4b0}.m{fill:#7c7c78}.k{fill:#2d2d2b}.p{fill:#232322}.ln{stroke:#363634}}
367.pulse{animation:kzp 1.6s ease-in-out infinite}@keyframes kzp{50%{opacity:.35}}
368.spin{transform-box:fill-box;transform-origin:center;animation:kzs 2s linear infinite}@keyframes kzs{to{transform:rotate(360deg)}}
369@media (prefers-reduced-motion: reduce){*{animation:none!important}}
370`
371
372export function svg(width: number, height: number, body: string, css = ''): string {
373  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><style>${SVG_BASE_CSS}${css}</style>${body}</svg>`
374}
375
376/** Rough width of UI text in px, for fitting labels. */
377export function textWidth(s: string, size: number): number {
378  let w = 0
379  for (const ch of s) w += (/[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%MWmw]/.test(ch) ? 0.72 : 0.56) * size
380  return w
381}
382
383export function fitText(s: string, size: number, maxW: number): string {
384  if (textWidth(s, size) <= maxW) return s
385  let out = ''
386  for (const ch of s) {
387    if (textWidth(out + ch + '…', size) > maxW) break
388    out += ch
389  }
390  return out + '…'
391}
392
393/** A rounded progress bar as SVG markup. */
394export function svgBar(x: number, y: number, w: number, h: number, ratio: number, color: string): string {
395  const fw = Math.max(0, Math.min(w, w * clamp01(ratio)))
396  return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${h}" rx="${h / 2}"/>` +
397    (fw > 0 ? `<rect x="${x}" y="${y}" width="${Math.max(h, fw)}" height="${h}" rx="${h / 2}" fill="${color}"/>` : '')
398}
399
400/** SVG text helper. */
401export function svgText(x: number, y: number, s: string, opts: { cls?: string; size?: number; weight?: number; anchor?: 'start' | 'middle' | 'end'; fill?: string; mono?: boolean } = {}): string {
402  const { cls = 't', size = 12, weight = 400, anchor = 'start', fill, mono = false } = opts
403  return `<text ${fill ? `fill="${fill}"` : `class="${cls}"`} x="${x}" y="${y}" font-family="${mono ? MONO : FONT}" font-size="${size}" font-weight="${weight}" text-anchor="${anchor}" font-variant-numeric="tabular-nums">${xml(s)}</text>`
404}
405
406/** Pixel width a desktop pane or band gives a drawing for `columns` reported columns. */
407export function pxOf(columns: number | undefined, fallback = 60, slack = 8): number {
408  return Math.max(200, Math.min(1600, (columns || fallback) * 8 - slack))
409}
410
411// ---------------------------------------------------------------------------
412// Prices, USD per million tokens: input, output, cache read, cache write (5 min).
413// Anthropic first-party rates as of 2026-10. Used only where the engine reports
414// tokens and not money (a subagent's own spend); the session total comes from
415// $.session.usage().cost, which is the engine's.
416
417const PRICES: [RegExp, [number, number, number, number]][] = [
418  [/fable|mythos/i, [10, 50, 0.25, 12.5]],
419  [/opus-5-5/i, [4, 20, 0.2, 5]],
420  [/opus/i, [5, 25, 0.5, 6.25]],
421  [/sonnet-5/i, [2, 10, 0.2, 2.5]],
422  [/sonnet/i, [3, 15, 0.3, 3.75]],
423  [/haiku-5/i, [0.1, 0.5, 0.01, 0.125]],
424  [/haiku/i, [1, 5, 0.1, 1.25]],
425]
426
427export type Usage = {
428  input_tokens?: number
429  output_tokens?: number
430  cache_read_input_tokens?: number
431  cache_creation_input_tokens?: number
432}
433
434export function priceOf(model: string): [number, number, number, number] {
435  return PRICES.find(([re]) => re.test(model))?.[1] ?? [4, 20, 0.2, 5]
436}
437
438export function costOf(model: string, u: Usage | null | undefined): number {
439  if (!u) return 0
440  const [i, o, r, w] = priceOf(model)
441  return ((u.input_tokens ?? 0) * i + (u.output_tokens ?? 0) * o + (u.cache_read_input_tokens ?? 0) * r + (u.cache_creation_input_tokens ?? 0) * w) / 1e6
442}
443
444export function tokensOf(u: Usage | null | undefined): number {
445  if (!u) return 0
446  return (u.input_tokens ?? 0) + (u.output_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
447}
448
449/** The context a request filled: everything it read plus what it wrote. */
450export function contextOf(u: Usage | null | undefined): number {
451  return tokensOf(u)
452}
453
454export function windowOf(model: string): number {
455  return /haiku-4/i.test(model) ? 200_000 : 1_000_000
456}
457
458// ---------------------------------------------------------------------------
459// Rate limits.
460
461export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
462
463export function limitLabel(kind: string): string {
464  if (kind === 'five_hour') return '5h'
465  if (kind === 'seven_day') return '7d'
466  if (kind === 'spend_limit') return '$'
467  return kind.replace(/_/g, ' ')
468}
469
470/** Milliseconds until a window resets, or undefined. */
471export function untilReset(l: Limit, now: number): number | undefined {
472  if (!l.resetsAt) return undefined
473  const t = Date.parse(l.resetsAt)
474  return Number.isFinite(t) ? Math.max(0, t - now) : undefined
475}
476
477/** The length of a window in ms, for burn-rate math. */
478export function windowMs(kind: string): number | undefined {
479  if (kind === 'five_hour') return 5 * 3600_000
480  if (kind === 'seven_day') return 7 * 24 * 3600_000
481  return undefined
482}
483
484// ---------------------------------------------------------------------------
485// Tool calls, described in a few words.
486
487/** What a tool call is about: the command, the file, the pattern, the agent's task. */
488export function toolDetail(tool: string, input: unknown): string {
489  const e = (input ?? {}) as Record<string, unknown>
490  const s = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
491  const t = String(tool)
492  if (t === 'Bash' || t === 'PowerShell') return clip(s('description') || s('command'), 60)
493  if (s('file_path')) return baseName(s('file_path'))
494  if (s('notebook_path')) return baseName(s('notebook_path'))
495  if (t === 'Agent') return clip(s('description') || s('subagent_type'), 60)
496  if (s('pattern')) return clip(s('pattern'), 60)
497  if (s('url')) return clip(s('url').replace(/^https?:\/\//, ''), 60)
498  if (s('query')) return clip(s('query'), 60)
499  if (s('subject')) return clip(s('subject'), 60)
500  if (Array.isArray(e.todos)) return `${(e.todos as unknown[]).length} todos`
501  if (t.startsWith('mcp__')) return t.split('__').slice(1).join(' · ')
502  return ''
503}
504
505/** `mcp__server__tool` → `server·tool`; built-ins unchanged. */
506export function toolName(tool: string): string {
507  const t = String(tool)
508  return t.startsWith('mcp__') ? t.split('__').slice(1).join('·') : t
509}
510
hooks/pad.ts 177 lines
1// Launchpad's pure helpers: ranking, argument parsing and the desktop tiles.
2// No `$` here (it may not cross an import).
3
4import type { LaunchpadData, PadCount } from '../types'
5import { KZ, clip, fitText, fmtSpan, hash, hue, svg, svgText, textWidth } from './lib/kz.ts'
6
7export const MAX_SAVED = 24
8export const MAX_FREQUENT = 8
9export const MAX_PROMPT = 2000
10
11export type Tile = { name: string; count: number; at: number; description: string }
12
13/** Commands this mod never offers to launch: itself. */
14const SKIP = new Set(['launchpad'])
15
16export function cleanName(s: string): string {
17  return s.trim().replace(/^\/+/, '').replace(/\s[\s\S]*$/, '')
18}
19
20/** Adds one run of `name` at `at` to the counts. */
21export function bump(counts: Record<string, PadCount>, name: string, at: number): Record<string, PadCount> {
22  const prev = counts[name]
23  return { ...counts, [name]: { n: (prev?.n ?? 0) + 1, at } }
24}
25
26const tileOf = (d: LaunchpadData, name: string): Tile => ({
27  name,
28  count: d.counts[name]?.n ?? 0,
29  at: d.counts[name]?.at ?? 0,
30  description: d.known[name] ?? '',
31})
32
33const isAvailable = (d: LaunchpadData, name: string): boolean => !SKIP.has(name) && (!d.hasList || name in d.known)
34
35export function pinnedTiles(d: LaunchpadData): Tile[] {
36  return d.pinned.filter(n => !SKIP.has(n)).map(n => tileOf(d, n))
37}
38
39/** The most-run commands, pinned ones left out, ties broken by recency. */
40export function frequentTiles(d: LaunchpadData, max = MAX_FREQUENT): Tile[] {
41  return Object.keys(d.counts)
42    .filter(n => !d.pinned.includes(n) && isAvailable(d, n))
43    .map(n => tileOf(d, n))
44    .sort((a, b) => b.count - a.count || b.at - a.at || a.name.localeCompare(b.name))
45    .slice(0, max)
46}
47
48export type PadAction =
49  | { kind: 'toggle' }
50  | { kind: 'list' }
51  | { kind: 'save'; text: string }
52  | { kind: 'rm'; index: number }
53  | { kind: 'pin'; name: string }
54  | { kind: 'unpin'; name: string }
55  | { kind: 'forget' }
56  | { kind: 'help'; reason: string }
57
58/** `/launchpad <verb> <rest>` into an action. */
59export function parseArgs(args: string): PadAction {
60  const trimmed = args.trim()
61  if (!trimmed) return { kind: 'toggle' }
62  // The first word is the verb; the rest, trimmed, its argument.
63  const gap = trimmed.search(/\s/)
64  const verb = (gap < 0 ? trimmed : trimmed.slice(0, gap)).toLowerCase()
65  const rest = gap < 0 ? '' : trimmed.slice(gap).trim()
66  if (verb === 'list' || verb === 'ls') return { kind: 'list' }
67  if (verb === 'save' || verb === 'add') return rest ? { kind: 'save', text: rest.slice(0, MAX_PROMPT) } : { kind: 'help', reason: 'Usage: /launchpad save <prompt text>' }
68  if (verb === 'rm' || verb === 'remove' || verb === 'del') {
69    const n = Number(rest)
70    return Number.isInteger(n) && n >= 1 ? { kind: 'rm', index: n - 1 } : { kind: 'help', reason: 'Usage: /launchpad rm <number> (see /launchpad list)' }
71  }
72  if (verb === 'pin' || verb === 'unpin') {
73    const name = cleanName(rest)
74    if (!name) return { kind: 'help', reason: `Usage: /launchpad ${verb} <command>` }
75    return verb === 'pin' ? { kind: 'pin', name } : { kind: 'unpin', name }
76  }
77  if (verb === 'forget' || verb === 'reset') return { kind: 'forget' }
78  return { kind: 'help', reason: `Unknown: ${verb}. Try save <text>, rm <n>, pin <cmd>, unpin <cmd>, list, forget.` }
79}
80
81export function listText(d: LaunchpadData): string {
82  const pinned = pinnedTiles(d)
83  const frequent = frequentTiles(d)
84  const lines = ['Launchpad']
85  lines.push('Pinned:', ...(pinned.length ? pinned.map(t => `  ★ /${t.name}${t.count ? ` ×${t.count}` : ''}`) : ['  (none: /launchpad pin <command>)']))
86  lines.push('Frequent:', ...(frequent.length ? frequent.map(t => `  ▶ /${t.name} ×${t.count}`) : ['  (none yet: run some slash commands)']))
87  lines.push('Saved:', ...(d.saved.length ? d.saved.map((s, i) => `  ${i + 1}. ${clip(s, 90)}`) : ['  (none: /launchpad save <prompt>)']))
88  return lines.join('\n')
89}
90
91/** A stable accent per command name. */
92export function accentOf(name: string): string {
93  return hue((hash(name) % 1000) / 1000, 0.8, 0.62)
94}
95
96export function agoText(at: number, now: number): string {
97  return at > 0 ? `${fmtSpan(now - at)} ago` : 'never run'
98}
99
100/** Where a saved prompt went, for the pane's last line. */
101export function fillNote(r: { isFilled: boolean; refusal?: 'no_composer' | 'dialog' }): string {
102  if (r.isFilled) return 'in the prompt box'
103  return r.refusal === 'dialog' ? 'a dialog holds the prompt box' : 'the prompt box did not take it'
104}
105
106/** A failure as one line of text. */
107export function errorText(err: unknown): string {
108  return err instanceof Error ? err.message : String(err)
109}
110
111// ---------------------------------------------------------------------------
112// Desktop tiles: one SVG card per row, its Buttons beside it.
113
114export function sectionSvg(label: string, sub: string, color: string, W: number): string {
115  const H = 26
116  const body = `<rect x="0" y="8" width="3" height="14" rx="1.5" fill="${color}"/>
117${svgText(10, 20, label.toUpperCase(), { size: 10.5, weight: 760, fill: color })}
118${svgText(16 + textWidth(label.toUpperCase(), 10.5) + 6, 20, sub, { cls: 'm', size: 10.5 })}`
119  return svg(W, H, body)
120}
121
122export function commandCardSvg(t: Tile, W: number, now: number, maxCount: number, isPinned: boolean): string {
123  const H = 56
124  const c = isPinned ? KZ.yellow : accentOf(t.name)
125  const initial = (t.name.replace(/[^a-z0-9]/gi, '').charAt(0) || '/').toUpperCase()
126  const parts = [
127    `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${c}" stop-opacity=".22"/><stop offset="1" stop-color="${c}" stop-opacity="0"/></linearGradient></defs>`,
128    `<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`,
129    `<rect x="0" y="0" width="${W}" height="${H}" rx="12" fill="url(#g)"/>`,
130    `<circle cx="28" cy="28" r="16" fill="${c}" opacity=".9"/>`,
131    svgText(28, 33.5, initial, { size: 15, weight: 800, anchor: 'middle', fill: '#14111f' }),
132  ]
133  if (isPinned) parts.push(`<path d="M44 10l1.6 3.3 3.6.5-2.6 2.5.6 3.6-3.2-1.7-3.2 1.7.6-3.6-2.6-2.5 3.6-.5z" fill="${KZ.yellow}"/>`)
134  const right = t.count ? `×${t.count}` : ''
135  parts.push(svgText(54, 25, fitText(`/${t.name}`, 15, W - 54 - 60), { size: 15, weight: 700, mono: true }))
136  parts.push(svgText(54, 43, fitText(t.description || agoText(t.at, now), 11, W - 54 - (right ? 66 : 14)), { cls: 's', size: 11 }))
137  if (right) {
138    parts.push(svgText(W - 12, 25, right, { size: 13, weight: 700, anchor: 'end', fill: c }))
139    const bw = 44
140    const ratio = maxCount > 0 ? t.count / maxCount : 0
141    parts.push(`<rect class="k" x="${W - 12 - bw}" y="${H - 11}" width="${bw}" height="3" rx="1.5"/><rect x="${W - 12 - bw}" y="${H - 11}" width="${Math.max(3, bw * ratio)}" height="3" rx="1.5" fill="${c}"/>`)
142  }
143  return svg(W, H, parts.join(''))
144}
145
146export function promptCardSvg(text: string, index: number, W: number): string {
147  const H = 56
148  const c = KZ.cyan
149  const one = text.replace(/\s+/g, ' ').trim()
150  // Two lines, cut by width.
151  const lineW = W - 56 - 12
152  const first = fitText(one, 12, lineW)
153  const restText = first.endsWith('…') ? one.slice(first.length - 1) : ''
154  const second = restText ? fitText(restText, 12, lineW) : ''
155  const parts = [
156    `<rect class="p" x="0" y="0" width="${W}" height="${H}" rx="12"/>`,
157    `<rect x="0" y="0" width="4" height="${H}" rx="2" fill="${c}"/>`,
158    `<rect x="14" y="12" width="30" height="30" rx="9" fill="${c}" opacity=".16"/>`,
159    svgText(29, 33, String(index + 1), { size: 14, weight: 800, anchor: 'middle', fill: c }),
160    svgText(56, 25, first, { size: 12, weight: 560 }),
161  ]
162  if (second) parts.push(svgText(56, 42, second, { cls: 's', size: 12 }))
163  return svg(W, H, parts.join(''))
164}
165
166export function bannerSvg(W: number, runs: number, kinds: number, saved: number): string {
167  const H = 64
168  const css = `.rk{transform-box:fill-box;transform-origin:center;animation:rk 3s ease-in-out infinite}@keyframes rk{50%{transform:translateY(-3px)}}
169.fl{animation:fl .5s ease-in-out infinite alternate}@keyframes fl{to{opacity:.4}}`
170  const body = `<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${KZ.violet}" stop-opacity=".28"/><stop offset="1" stop-color="${KZ.cyan}" stop-opacity=".08"/></linearGradient></defs>
171<rect class="p" width="${W}" height="${H}" rx="14"/><rect width="${W}" height="${H}" rx="14" fill="url(#bg)"/>
172<g transform="translate(30 32)"><g class="rk"><path d="M0-15c6 4 8 11 6 18h-12c-2-7 0-14 6-18z" fill="${KZ.violet}"/><circle cx="0" cy="-3" r="3" fill="#fff"/><path d="M-6 3l-5 6 5-1zM6 3l5 6-5-1z" fill="${KZ.magenta}"/><path class="fl" d="M-4 6l4 9 4-9z" fill="${KZ.amber}"/></g></g>
173${svgText(56, 30, 'LAUNCHPAD', { size: 17, weight: 800, fill: KZ.violet })}
174${svgText(56, 48, `${runs} run${runs === 1 ? '' : 's'} learned · ${kinds} command${kinds === 1 ? '' : 's'} · ${saved} saved prompt${saved === 1 ? '' : 's'}`, { cls: 's', size: 11 })}`
175  return svg(W, H, body, css)
176}
177
types/index.d.ts 23 lines
1export type PadCount = { n: number; at: number }
2
3export type LaunchpadData = {
4  /** How often the person ran each slash command (by name, no slash), and when last. */
5  counts: Record<string, PadCount>
6  /** Pinned command names, in pin order. */
7  pinned: string[]
8  /** Saved prompts, oldest first. */
9  saved: string[]
10  /** Command name → description, from $.command.list(); empty until read. */
11  known: Record<string, string>
12  /** True once $.command.list() answered, so unknown names can be hidden. */
13  hasList: boolean
14  /** The last launch and what came of it. */
15  lastRun?: { label: string; text: string }
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    launchpad: { pad: LaunchpadData }
21  }
22}
23