SLOPSHOPPER

breadcrumbs

KOZMOS Breadcrumbs: the session's web trail in a sidebar. WebSearch queries with result counts and top hits, WebFetch URLs with status and size, and…

newpaneguardcommandtoastagents
★ 4v0.1.0MITupdated 2026-10-09ersinkoc/claude-mods/mods/breadcrumbs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · breadcrumbs
│ ┃ KOZMOS · Breadcrumbs ✕ › fix the failing auth test and add an audit log call │ ┃ ◍ BREADCRUMBS 0 steps │ ┃ 0 fetches · 0 searches · 0 domains ⏺ Read(src/auth.ts) │ ┃ No web steps yet. WebSearch, WebFetch and ⎿ Read 6 lines │ ┃ browser tools leave their trail here. ⏺ 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 │ │ › /breadcrumbs │ ⎿ breadcrumbs: Breadcrumbs open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · KOZMOS · Breadcrumbs
◍ BREADCRUMBS 0 steps 0 fetches · 0 searches · 0 domains No web steps yet. WebSearch, WebFetch and browser tools leave their trail here.
README

KOZMOS Breadcrumbs

The session's web trail in a sidebar. Every WebSearch (query, how many results the search reported, the first five hits), every WebFetch (domain, path, HTTP status, size, the final URL after redirects) and every browser-like MCP tool visit (an mcp__… tool called with a url) is recorded, from the main loop and from subagents.

  • Grouped by domain, most recent first, each group with a colored two-letter monogram badge, visit count, last time, bytes fetched and failures; folds with ▾ / ▸.
  • Every URL and search hit is a Link: click to open it (OSC 8 on the terminal, an anchor on the desktop).
  • copy all as Markdown puts the whole trail on the clipboard as a nested list grouped by domain.
  • At most 300 steps kept.

/breadcrumbs toggles the pane, /breadcrumbs md prints the Markdown. Set autoOpen to open it on session start.

Türkçe

Oturumun web izini kenar çubuğunda gösterir. Her WebSearch (sorgu, aramanın bildirdiği sonuç sayısı, ilk beş sonuç), her WebFetch (alan adı, yol, HTTP durumu, boyut, yönlendirme sonrası son URL) ve tarayıcı benzeri her MCP aracı ziyareti (url ile çağrılan bir mcp__… aracı) ana döngüden ve alt ajanlardan kaydedilir.

  • Alan adına göre gruplanır; her grubun renkli iki harfli monogram rozeti, ziyaret sayısı, son zamanı, indirilen bayt ve hataları vardır; ▾ / ▸ ile katlanır.
  • Her URL ve arama sonucu tıklanabilir bir Link'tir.
  • copy all as Markdown tüm izi alan adına göre gruplanmış iç içe bir liste olarak panoya kopyalar.
  • En fazla 300 adım tutulur.

/breadcrumbs paneli açar/kapatır, /breadcrumbs md Markdown'ı yazar. Oturum başında açılması için autoOpen ayarını açın.

Source 4 files
hooks/register.tsx 284 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { BreadcrumbsSnap, BreadcrumbsView, Crumb } from '../types'
5import { KZ, clip, pxOf } from './lib/kz.ts'
6import {
7  MAX_CRUMBS, SEARCH, altOfGroup, classify, clockOf, domainColor, emptySnap, errorOutcome, fetchOutcome, fmtBytes, fmtMs,
8  groupSvg, groupsOf, headerSvg, monogram, rowLabel, searchOutcome, splitUrl, statusColor, statusLabel, toMarkdown,
9} from './trail.ts'
10
11const PANE = 'kz-breadcrumbs'
12const TITLE = 'KOZMOS · Breadcrumbs'
13const PER_GROUP = 15
14const snapAtom = atom({ plugin: 'breadcrumbs', key: 'snap' } as const, null)
15const viewAtom = atom({ plugin: 'breadcrumbs', key: 'view' } as const, { folded: [] })
16
17let live: BreadcrumbsSnap = emptySnap()
18let isSeeded = false
19let seq = 0
20const agentNames = new Map<string, string>()
21
22async function seed($: EngineInterface): Promise<void> {
23  if (isSeeded) return
24  isSeeded = true
25  try {
26    const kept = await read($, snapAtom)
27    if (kept) live = structuredClone(kept)
28  } catch {
29    // Nothing kept: start empty.
30  }
31}
32
33/** Every call follows a change to the trail, so each one writes. */
34async function publish($: EngineInterface): Promise<void> {
35  const next = structuredClone(live)
36  await update($, snapAtom, () => next)
37}
38
39async function whoOf($: EngineInterface, agentId: string | undefined): Promise<string> {
40  if (agentId === undefined) return 'main'
41  const known = agentNames.get(agentId)
42  if (known) return known
43  try {
44    for (const a of await $.agent.list()) agentNames.set(a.id, a.description || a.type)
45  } catch {
46    // The roster is not readable now; fall back to the id.
47  }
48  return agentNames.get(agentId) ?? `agent ${agentId.slice(0, 6)}`
49}
50
51async function toggle($: EngineInterface): Promise<boolean> {
52  if ((await $.ui.panes()).some(p => p.id === PANE)) {
53    await $.ui.close({ id: PANE })
54    return false
55  }
56  await $.ui.open({ id: PANE, title: TITLE })
57  return true
58}
59
60async function copyTrail($: EngineInterface, surface: RenderSurface): Promise<void> {
61  // The copy button shows only once the trail has a step, so there is a snapshot and Markdown.
62  const snap = (await read($, snapAtom)) as BreadcrumbsSnap
63  const r = await $.ui.copy({ text: toMarkdown(snap), surface })
64  if (r.isCopied) $.ui.toast(`Breadcrumbs: ${snap.crumbs.length} steps copied as Markdown`)
65}
66
67function put(c: Crumb): void {
68  live.crumbs = [c, ...live.crumbs.filter(x => x.id !== c.id)].slice(0, MAX_CRUMBS)
69}
70
71export const register: Register = (on, options) => {
72  on('session.start', async ($, e, next) => {
73    const started = await next(e)
74    live = emptySnap()
75    isSeeded = true
76    agentNames.clear()
77    await $.command.register({ name: 'breadcrumbs', description: 'KOZMOS: toggle the Breadcrumbs web-trail sidebar (/breadcrumbs md prints the trail as Markdown)', argumentHint: '[md]', immediate: true })
78    await publish($).catch(() => undefined)
79    if (options.autoOpen === true) void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
80    return started
81  })
82
83  on('command.run', { command: 'breadcrumbs' }, async ($, e) => {
84    if (e.args.trim() === 'md') {
85      await seed($)
86      return { text: toMarkdown(live) || 'Breadcrumbs: no web steps yet.' }
87    }
88    return { text: (await toggle($)) ? 'Breadcrumbs open.' : 'Breadcrumbs closed.' }
89  })
90
91  on('agent.spawn', async ($, e, next) => {
92    const r = await next(e)
93    if (r.deny === undefined && r.agentId) agentNames.set(r.agentId, e.description || e.subagentType)
94    return r
95  }).catch(($, e, next) => next(e))
96
97  on('tool.call', async ($, e, next) => {
98    const tool = String(e.tool)
99    const input = e as unknown as Record<string, unknown>
100    const what = classify(tool, input)
101    if (!what) return next(e)
102    await seed($)
103    const at = await $.clock.now()
104    const where = what.url ? splitUrl(what.url) : { domain: SEARCH, path: undefined }
105    const crumb: Crumb = {
106      id: e.tool_use_id || `c${++seq}`,
107      kind: what.kind,
108      tool,
109      at,
110      domain: what.kind === 'search' ? SEARCH : where.domain,
111      url: what.url,
112      path: where.path,
113      query: what.query,
114      isRunning: true,
115      isError: false,
116      who: await whoOf($, e.agentId),
117    }
118    if (what.kind === 'search') live.searches++
119    else if (what.kind === 'fetch') live.fetches++
120    else live.browses++
121    put(crumb)
122    void publish($).catch(() => undefined)
123
124    const ran = await next(e)
125    const done: Crumb = { ...crumb, isRunning: false, ms: Math.max(0, (await $.clock.now()) - at) }
126    if (ran.deny !== undefined || ran.isError) {
127      const text = ran.deny ?? ran.text ?? (typeof ran.result === 'string' ? ran.result : 'failed')
128      const err = errorOutcome(text)
129      done.isError = true
130      done.error = err.error
131      done.status = err.status
132      live.failures++
133    } else if (what.kind === 'search') {
134      const s = tool === 'WebSearch' ? searchOutcome(ran.result) : { results: undefined, hits: [] }
135      done.results = s.results
136      done.hits = s.hits
137    } else if (what.kind === 'fetch') {
138      const f = fetchOutcome(ran.result)
139      done.status = f.status
140      done.statusText = f.statusText
141      done.bytes = f.bytes
142      if (f.url && f.url !== crumb.url) {
143        const moved = splitUrl(f.url)
144        done.url = f.url
145        done.domain = moved.domain
146        done.path = moved.path
147      }
148    } else if (typeof ran.text === 'string') {
149      done.bytes = ran.text.length
150    }
151    put(done)
152    await publish($).catch(() => undefined)
153    return ran
154  }).catch(($, e, next) => next(e))
155
156  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
157    const snap = (await read($, snapAtom)) ?? emptySnap()
158    const view: BreadcrumbsView = await read($, viewAtom)
159    const ui = $.ui.resolve(e)
160    const { Box, Text, Button, Link } = ui
161    const groups = groupsOf(snap.crumbs)
162    const domains = groups.filter(g => g.domain !== SEARCH).length
163    const fold = (domain: string) => update($, viewAtom, v => ({
164      folded: v.folded.includes(domain) ? v.folded.filter(d => d !== domain) : [...v.folded, domain].slice(-100),
165    }))
166    const copyButton = snap.crumbs.length > 0
167      ? <Button key="copy-md" label="⧉ copy all as Markdown" dimColor onPress={p => void copyTrail($, p.surface).catch(() => undefined)} />
168      : null
169    const empty = snap.crumbs.length === 0
170      ? <Text key="empty" dimColor>No web steps yet. WebSearch, WebFetch and browser tools leave their trail here.</Text>
171      : null
172    const who = (c: Crumb) => (c.who === 'main' ? '' : ` · ◈ ${clip(c.who, 24)}`)
173
174    if ('Svg' in ui && e.surface !== 'terminal') {
175      const { Svg } = ui
176      const W = pxOf(e.props.bodyColumns, 44)
177      const head = headerSvg(snap, groups, W)
178      return (
179        <Box flexDirection="column" gap={1}>
180          <Svg key="head" source={head.source} alt={`Breadcrumbs: ${snap.fetches} fetches, ${snap.searches} searches, ${domains} domains`} width={W} height={head.height} />
181          {copyButton}
182          {groups.map(g => {
183            const isFolded = view.folded.includes(g.domain)
184            const card = groupSvg(g, W - 44, isFolded)
185            return (
186              <Box key={`g-${g.domain}`} flexDirection="column">
187                <Box key={`gh-${g.domain}`} flexDirection="row" alignItems="center" gap={1}>
188                  <Svg key={`gs-${g.domain}`} source={card.source} alt={altOfGroup(g)} width={W - 44} height={card.height} />
189                  <Button key={`fold-${g.domain}`} label={isFolded ? '▸' : '▾'} plain onPress={() => fold(g.domain)} />
190                </Box>
191                {!isFolded && g.crumbs.slice(0, PER_GROUP).map(c => (
192                  <Box key={`c-${c.id}`} flexDirection="column" paddingLeft={2}>
193                    {c.kind === 'search'
194                      ? <Text key={`q-${c.id}`} wrap="truncate-end">{clockOf(c.at)} · {rowLabel(c)}{who(c)}</Text>
195                      : <Link key={`l-${c.id}`} href={c.url as string} label={`${clockOf(c.at)} · ${rowLabel(c)}${who(c)}`} />}
196                    {c.error ? <Text key={`e-${c.id}`} color={KZ.red} wrap="truncate-end">  {c.error}</Text> : null}
197                    {(c.hits ?? []).map((hit, i) => <Link key={`h-${c.id}-${i}`} href={hit.url} label={`  ↗ ${hit.title}`} />)}
198                  </Box>
199                ))}
200                {!isFolded && g.crumbs.length > PER_GROUP ? <Text key={`m-${g.domain}`} dimColor>  … {g.crumbs.length - PER_GROUP} more</Text> : null}
201              </Box>
202            )
203          })}
204          {empty}
205        </Box>
206      )
207    }
208
209    return (
210      <Box flexDirection="column">
211        <Box flexDirection="row" justifyContent="space-between">
212          <Text bold color={KZ.cyan}>◍ BREADCRUMBS</Text>
213          <Text dimColor>{snap.crumbs.length} steps</Text>
214        </Box>
215        <Text wrap="truncate-end">
216          <Text color={KZ.cyan}>{snap.fetches} fetches</Text>
217          <Text dimColor> · </Text>
218          <Text color={KZ.violet}>{snap.searches} searches</Text>
219          <Text dimColor> · </Text>
220          <Text color={KZ.teal}>{domains} domains</Text>
221          {snap.browses ? <Text dimColor> · {snap.browses} browser</Text> : ''}
222          {snap.failures ? <Text color={KZ.red}> · {snap.failures} failed</Text> : ''}
223        </Text>
224        {snap.crumbs.length > 0 && (
225          <Text wrap="truncate-end">
226            {groups.map(g => (
227              <Text key={`bar-${g.domain}`} color={domainColor(g.domain)}>{'▆'.repeat(Math.max(1, Math.round((g.crumbs.length / snap.crumbs.length) * Math.max(10, (e.props.bodyColumns || 44) - 4))))}</Text>
228            ))}
229          </Text>
230        )}
231        {copyButton}
232        {groups.map(g => {
233          const isFolded = view.folded.includes(g.domain)
234          const color = domainColor(g.domain)
235          return (
236            <Box key={`g-${g.domain}`} flexDirection="column" marginTop={1}>
237              <Box flexDirection="row" justifyContent="space-between">
238                <Text wrap="truncate-end">
239                  <Text backgroundColor={color} color="#111111" bold> {monogram(g.domain)} </Text>
240                  <Text bold> {g.domain === SEARCH ? 'web search' : g.domain}</Text>
241                  <Text dimColor> · {g.crumbs.length} · {clockOf(g.last)}{g.bytes ? ` · ${fmtBytes(g.bytes)}` : ''}</Text>
242                  {g.failures ? <Text color={KZ.red}> · {g.failures}✖</Text> : ''}
243                </Text>
244                <Button key={`fold-${g.domain}`} label={isFolded ? '▸' : '▾'} plain dimColor onPress={() => fold(g.domain)} />
245              </Box>
246              {!isFolded && g.crumbs.slice(0, PER_GROUP).map(c => (
247                <Box key={`c-${c.id}`} flexDirection="column">
248                  {c.kind === 'search'
249                    ? (
250                      <Text wrap="truncate-end">
251                        <Text dimColor>  {clockOf(c.at)} </Text>
252                        <Text color={KZ.violet}>⌕ </Text>
253                        <Text>“{c.query}”</Text>
254                        <Text dimColor>{c.results !== undefined ? ` · ${c.results} results` : ''} · {fmtMs(c.ms)}{who(c)}</Text>
255                      </Text>
256                    )
257                    : (
258                      <Text wrap="truncate-end">
259                        <Text dimColor>  {clockOf(c.at)} </Text>
260                        <Text color={statusColor(c)}>{statusLabel(c).padEnd(3)} </Text>
261                        {c.bytes !== undefined ? <Text dimColor>{fmtBytes(c.bytes)} </Text> : ''}
262                        <Link href={c.url as string}>{`↗ ${c.path}`}</Link>
263                        <Text dimColor>{who(c)}</Text>
264                      </Text>
265                    )}
266                  {c.error ? <Text color={KZ.red} wrap="truncate-end">      └ {c.error}</Text> : null}
267                  {(c.hits ?? []).map((hit, i) => (
268                    <Text key={`h-${c.id}-${i}`} wrap="truncate-end">
269                      <Text dimColor>        ↗ </Text>
270                      <Link href={hit.url}>{hit.title}</Link>
271                    </Text>
272                  ))}
273                </Box>
274              ))}
275              {!isFolded && g.crumbs.length > PER_GROUP ? <Text dimColor>  … {g.crumbs.length - PER_GROUP} more</Text> : null}
276            </Box>
277          )
278        })}
279        {empty}
280      </Box>
281    )
282  })
283}
284
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/trail.ts 244 lines
1// Breadcrumbs: pure helpers (no `$`): reading web tool calls, grouping by
2// domain, Markdown export and the desktop drawings.
3import type { BreadcrumbsSnap, Crumb, CrumbHit, CrumbKind } from '../types'
4import { KZ, clip, fitText, hash, hue, svg, svgText, xml } from './lib/kz.ts'
5
6export const MAX_CRUMBS = 300
7export const MAX_HITS = 5
8export const SEARCH = 'search'
9
10export const emptySnap = (): BreadcrumbsSnap => ({ crumbs: [], searches: 0, fetches: 0, browses: 0, failures: 0 })
11
12const BROWSER_TOOL = /browser|chrome|playwright|puppeteer|navigate|firecrawl|scrape|crawl|fetch|web/i
13
14/** Whether a tool call is a web step, and what it asks for. */
15export function classify(tool: string, input: Record<string, unknown>): { kind: CrumbKind; url?: string; query?: string } | undefined {
16  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string).trim() : '')
17  if (tool === 'WebFetch') return str('url') ? { kind: 'fetch', url: str('url') } : undefined
18  if (tool === 'WebSearch') return { kind: 'search', query: str('query') }
19  if (!tool.startsWith('mcp__')) return undefined
20  const url = str('url') || str('href') || str('uri')
21  if (url && /^https?:\/\//i.test(url)) return { kind: 'browse', url }
22  if (/search/i.test(tool) && (str('query') || str('q'))) return { kind: 'search', query: str('query') || str('q') }
23  if (url && BROWSER_TOOL.test(tool)) return { kind: 'browse', url }
24  return undefined
25}
26
27/** `https://www.github.com/a/b?q=1` → `github.com`, `/a/b?q=1`. */
28export function splitUrl(url: string): { domain: string; path: string } {
29  try {
30    const u = new URL(url)
31    const domain = u.hostname.replace(/^www\./i, '').toLowerCase() || u.protocol.replace(/:$/, '')
32    const path = `${u.pathname}${u.search}` || '/'
33    return { domain, path }
34  } catch {
35    const m = /^(?:[a-z]+:\/\/)?(?:www\.)?([^/?#\s]+)(.*)$/i.exec(url)
36    return { domain: (m?.[1] ?? url).toLowerCase(), path: m?.[2] || '/' }
37  }
38}
39
40/** What a WebSearch result says: how many hits, and the first few. */
41export function searchOutcome(result: unknown): { results?: number; hits: CrumbHit[] } {
42  const r = (result ?? {}) as Record<string, unknown>
43  const blocks = Array.isArray(r.results) ? (r.results as unknown[]) : []
44  const hits: CrumbHit[] = []
45  let n = 0
46  let sawList = false
47  for (const b of blocks) {
48    if (!b || typeof b !== 'object') continue
49    const content = (b as Record<string, unknown>).content
50    if (!Array.isArray(content)) continue
51    sawList = true
52    for (const c of content) {
53      const hit = c as Record<string, unknown>
54      if (typeof hit.url !== 'string') continue
55      n++
56      if (hits.length < MAX_HITS) hits.push({ title: clip(typeof hit.title === 'string' && hit.title ? hit.title : hit.url, 90), url: hit.url })
57    }
58  }
59  return { results: sawList ? n : undefined, hits }
60}
61
62/** What a WebFetch result says: status, size and the URL it ended at. */
63export function fetchOutcome(result: unknown): { status?: number; statusText?: string; bytes?: number; url?: string } {
64  const r = (result ?? {}) as Record<string, unknown>
65  return {
66    status: typeof r.code === 'number' ? r.code : undefined,
67    statusText: typeof r.codeText === 'string' && r.codeText ? r.codeText : undefined,
68    bytes: typeof r.bytes === 'number' ? r.bytes : undefined,
69    url: typeof r.url === 'string' && r.url ? r.url : undefined,
70  }
71}
72
73/** An error's first line, and the HTTP status it names. */
74export function errorOutcome(text: string): { status?: number; error: string } {
75  const line = text.split(/\r?\n/).map(l => l.trim()).find(Boolean) ?? 'failed'
76  const m = /\b(?:status(?: code)?|HTTP)\s*:?\s*([1-5]\d\d)\b/i.exec(text)
77  return { status: m ? Number(m[1]) : undefined, error: clip(line, 140) }
78}
79
80/** Two letters for a domain: `docs.python.org` → `Py`, `github.com` → `Gi`. */
81export function monogram(domain: string): string {
82  if (domain === SEARCH) return '⌕'
83  const parts = domain.split('.').filter(Boolean)
84  let main = parts[0] ?? domain
85  if (parts.length >= 2) {
86    main = parts[parts.length - 2] as string
87    if (parts.length >= 3 && /^(co|com|org|net|ac|gov|edu|ne|or)$/.test(main)) main = parts[parts.length - 3] as string
88  }
89  const letters = main.replace(/[^a-z0-9]/gi, '')
90  return (letters.charAt(0).toUpperCase() + letters.charAt(1).toLowerCase()) || '·'
91}
92
93export function domainColor(domain: string): string {
94  return domain === SEARCH ? KZ.cyan : hue((hash(domain) % 997) / 997, 0.62, 0.58)
95}
96
97export function statusColor(c: Crumb): string {
98  if (c.isRunning) return KZ.violet
99  if (c.isError || (c.status ?? 0) >= 400) return KZ.red
100  if ((c.status ?? 0) >= 300) return KZ.amber
101  return KZ.green
102}
103
104export type Group = { domain: string; crumbs: Crumb[]; last: number; failures: number; bytes: number }
105
106/** Crumbs grouped by domain, the most recently visited group first. */
107export function groupsOf(crumbs: readonly Crumb[]): Group[] {
108  const by = new Map<string, Group>()
109  for (const c of crumbs) {
110    let g = by.get(c.domain)
111    if (!g) {
112      g = { domain: c.domain, crumbs: [], last: 0, failures: 0, bytes: 0 }
113      by.set(c.domain, g)
114    }
115    g.crumbs.push(c)
116    g.last = Math.max(g.last, c.at)
117    if (c.isError) g.failures++
118    g.bytes += c.bytes ?? 0
119  }
120  return [...by.values()].sort((a, b) => b.last - a.last)
121}
122
123export function fmtBytes(n: number | undefined): string {
124  if (n === undefined) return ''
125  if (n < 1000) return `${n} B`
126  if (n < 1e6) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)} kB`
127  return `${(n / 1e6).toFixed(1)} MB`
128}
129
130export function fmtMs(ms: number | undefined): string {
131  if (ms === undefined) return '…'
132  return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`
133}
134
135export function clockOf(at: number): string {
136  const d = new Date(at)
137  const p = (n: number) => String(n).padStart(2, '0')
138  return `${p(d.getHours())}:${p(d.getMinutes())}`
139}
140
141/** The status cell of a row: `200`, `404`, `ERR`, `…`. */
142export function statusLabel(c: Crumb): string {
143  if (c.isRunning) return '…'
144  if (c.status !== undefined) return String(c.status)
145  return c.isError ? 'ERR' : c.kind === 'browse' ? 'ok' : '—'
146}
147
148/** One row's text: `14:09 · 200 · 12 kB · /path`. */
149export function rowLabel(c: Crumb): string {
150  // A search always has its query; a visit always has its URL's path.
151  if (c.kind === 'search') return `⌕ “${c.query}”${c.results !== undefined ? ` · ${c.results} results` : ''}`
152  return [statusLabel(c), fmtBytes(c.bytes), c.path].filter(Boolean).join(' · ')
153}
154
155/** The whole trail as a nested Markdown list, grouped by domain. */
156export function toMarkdown(snap: BreadcrumbsSnap): string {
157  const lines: string[] = []
158  for (const g of groupsOf(snap.crumbs)) {
159    lines.push(`- **${g.domain === SEARCH ? 'Web search' : g.domain}**`)
160    for (const c of [...g.crumbs].reverse()) {
161      if (c.kind === 'search') {
162        lines.push(`  - 🔎 "${c.query}"${c.results !== undefined ? ` (${c.results} results)` : ''}`)
163        for (const h of c.hits ?? []) lines.push(`    - [${h.title.replace(/[[\]]/g, '')}](${h.url})`)
164      } else {
165        // A visit always has its URL and that URL's path.
166        const bits = [c.status !== undefined ? String(c.status) : c.isError ? 'error' : '', fmtBytes(c.bytes)].filter(Boolean).join(', ')
167        lines.push(`  - [${(c.path as string).replace(/[[\]]/g, '')}](${c.url})${bits ? ` — ${bits}` : ''}`)
168      }
169    }
170  }
171  return lines.join('\n')
172}
173
174// ---------------------------------------------------------------------------
175// Desktop drawings.
176
177export function headerSvg(s: BreadcrumbsSnap, groups: readonly Group[], W: number): { source: string; height: number } {
178  const H = 118
179  const parts: string[] = []
180  parts.push(`<defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${KZ.cyan}" stop-opacity=".15"/><stop offset="1" stop-color="${KZ.violet}" stop-opacity=".06"/></linearGradient></defs>`)
181  parts.push(`<rect class="p" width="${W}" height="${H}" rx="14"/><rect width="${W}" height="${H}" rx="14" fill="url(#bg)"/>`)
182  // A dotted trail with footprints.
183  parts.push(`<path d="M16 34 C 30 18, 42 44, 58 28" fill="none" stroke="${KZ.cyan}" stroke-width="1.6" stroke-dasharray="2 4" stroke-linecap="round"/>`)
184  parts.push(`<circle cx="58" cy="28" r="4" fill="${KZ.cyan}" class="pulse"/>`)
185  parts.push(svgText(70, 26, 'BREADCRUMBS', { size: 13, weight: 800 }))
186  parts.push(svgText(70, 40, 'the web trail of this session', { cls: 'm', size: 10.5 }))
187  const tiles: [string, number, string][] = [
188    ['FETCHES', s.fetches, KZ.cyan],
189    ['SEARCHES', s.searches, KZ.violet],
190    ['DOMAINS', groups.filter(g => g.domain !== SEARCH).length, KZ.teal],
191    ['FAILED', s.failures, s.failures ? KZ.red : KZ.mist],
192  ]
193  const tw = (W - 28 - 18) / 4
194  tiles.forEach(([k, v, c], i) => {
195    const x = 14 + i * (tw + 6)
196    parts.push(`<rect class="k" x="${x}" y="52" width="${tw}" height="36" rx="9" opacity=".7"/>`)
197    parts.push(svgText(x + tw / 2, 66, k, { cls: 's', size: 8.5, weight: 650, anchor: 'middle' }))
198    parts.push(svgText(x + tw / 2, 82, String(v), { size: 13.5, weight: 750, anchor: 'middle', fill: c }))
199  })
200  // Share of visits per domain.
201  const total = s.crumbs.length
202  let x = 14
203  const bw = W - 28
204  if (total) {
205    for (const g of groups) {
206      const w = (g.crumbs.length / total) * bw
207      parts.push(`<rect x="${x.toFixed(1)}" y="98" width="${Math.max(1, w - 1.5).toFixed(1)}" height="8" rx="3" fill="${domainColor(g.domain)}"><title>${xml(`${g.domain}: ${g.crumbs.length}`)}</title></rect>`)
208      x += w
209    }
210  } else {
211    parts.push(`<rect class="k" x="14" y="98" width="${bw}" height="8" rx="3"/>`)
212  }
213  return { source: svg(W, H + 2, parts.join('')), height: H + 2 }
214}
215
216export function groupSvg(g: Group, W: number, isFolded: boolean): { source: string; height: number } {
217  const H = 50
218  const c = domainColor(g.domain)
219  const parts: string[] = []
220  parts.push(`<rect class="p" width="${W}" height="${H}" rx="12"/>`)
221  parts.push(`<circle cx="26" cy="25" r="15" fill="${c}"/>`)
222  const mono = monogram(g.domain)
223  parts.push(svgText(26, 30, mono, { size: mono.length > 1 ? 12 : 15, weight: 800, anchor: 'middle', fill: '#111' }))
224  const label = g.domain === SEARCH ? 'Web search' : g.domain
225  const countText = `${g.crumbs.length} ${g.domain === SEARCH ? (g.crumbs.length === 1 ? 'query' : 'queries') : g.crumbs.length === 1 ? 'visit' : 'visits'}`
226  parts.push(svgText(50, 22, fitText(label, 13, W - 140), { size: 13, weight: 700 }))
227  const meta = [countText, `last ${clockOf(g.last)}`, g.bytes ? fmtBytes(g.bytes) : '', g.failures ? `${g.failures} failed` : ''].filter(Boolean).join(' · ')
228  parts.push(svgText(50, 38, fitText(meta, 10, W - 120), { cls: 'm', size: 10 }))
229  // Status dots, oldest left.
230  const dots = [...g.crumbs].slice(0, 12).reverse()
231  let dx = W - 14 - dots.length * 9
232  for (const d of dots) {
233    const sc = statusColor(d)
234    parts.push(`<circle cx="${dx + 4}" cy="25" r="3.4" fill="${sc}" class="${d.isRunning ? 'pulse' : ''}"/>`)
235    dx += 9
236  }
237  if (isFolded) parts.push(svgText(W - 14, 42, 'folded', { cls: 'm', size: 9, anchor: 'end' }))
238  return { source: svg(W, H, parts.join('')), height: H }
239}
240
241export function altOfGroup(g: Group): string {
242  return `${g.domain === SEARCH ? 'Web search' : g.domain}: ${g.crumbs.length} steps, last at ${clockOf(g.last)}${g.failures ? `, ${g.failures} failed` : ''}`
243}
244
types/index.d.ts 60 lines
1/** What kind of step on the web: a search, a fetched page, a browser-like MCP tool visit. */
2export type CrumbKind = 'search' | 'fetch' | 'browse'
3
4export type CrumbHit = { title: string; url: string }
5
6/** One web step of the session (WebSearch, WebFetch, a browser-like MCP tool). */
7export type Crumb = {
8  id: string
9  kind: CrumbKind
10  /** The tool that made it (`WebFetch`, `WebSearch`, `mcp__…`). */
11  tool: string
12  /** When it started, `$.clock.now()` milliseconds. */
13  at: number
14  /** How long it took; absent while it runs. */
15  ms?: number
16  /** The group: the host without `www.`, or `search` for searches. */
17  domain: string
18  /** The URL visited (the final one after redirects, when the result says). */
19  url?: string
20  /** Path and query of `url`, for the row label. */
21  path?: string
22  /** The search query. */
23  query?: string
24  /** How many results the search said it found. */
25  results?: number
26  /** The first few search hits. */
27  hits?: CrumbHit[]
28  /** HTTP status and its text, when the fetch said. */
29  status?: number
30  statusText?: string
31  /** Size of the fetched content in bytes, when the fetch said. */
32  bytes?: number
33  isRunning: boolean
34  isError: boolean
35  /** The first line of the error. */
36  error?: string
37  /** `main`, or the subagent's description. */
38  who: string
39}
40
41export type BreadcrumbsSnap = {
42  /** Newest first, at most 300. */
43  crumbs: Crumb[]
44  searches: number
45  fetches: number
46  browses: number
47  failures: number
48}
49
50export type BreadcrumbsView = {
51  /** Domains folded to their header. */
52  folded: string[]
53}
54
55declare module 'claude-code' {
56  interface PluginState {
57    breadcrumbs: { snap: BreadcrumbsSnap | null; view: BreadcrumbsView }
58  }
59}
60