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…

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.
Link: click to open it (OSC 8 on the terminal, an anchor on the desktop)./breadcrumbs toggles the pane, /breadcrumbs md prints the Markdown. Set autoOpen to open it on session start.
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.
Link'tir./breadcrumbs paneli açar/kapatır, /breadcrumbs md Markdown'ı yazar. Oturum başında açılması için autoOpen ayarını açın.
hooks/register.tsx 284 lines1import { 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}
284hooks/lib/kz.ts 510 lines1// 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
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}
510hooks/trail.ts 244 lines1// 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}
244types/index.d.ts 60 lines1/** 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