typebulb 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3262 @@
1
+ ---
2
+ format: typebulb/v1
3
+ name: Claude Bulb
4
+ ---
5
+
6
+ **server.ts**
7
+
8
+ ```ts
9
+ import { existsSync, openSync, readSync, closeSync, statSync, readdirSync, watchFile, unwatchFile, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'fs'
10
+ import { join } from 'path'
11
+ import { homedir } from 'os'
12
+ import { spawn } from 'child_process'
13
+ import { launchBulbServer, listBulbServers, stopBulbServer, readServerLog, listBulbFiles as listProjectBulbFiles, slugifyBulbName, bulbName, isBulbTrusted, setBulbTrusted, predictBulbTrust } from 'typebulb/servers'
14
+
15
+ function errorMessage(err: unknown): string {
16
+ return (err as Error)?.message ?? String(err)
17
+ }
18
+
19
+ // The bulb tails CC's on-disk JSONL transcript and renders it; it drives nothing.
20
+ // Native only: it mirrors the real ~/.claude transcripts your terminal CC writes.
21
+ // See Specs-Bulbs/Claude-Bulb/Claude-Bulb.md for the architecture.
22
+
23
+ // Only the fields we read; real lines carry many more.
24
+ interface JsonlEntry {
25
+ uuid: string
26
+ type: string
27
+ isSidechain?: boolean
28
+ parentUuid?: string
29
+ timestamp?: string
30
+ sessionId?: string
31
+ aiTitle?: string
32
+ customTitle?: string
33
+ message?: {
34
+ id?: string
35
+ model?: string
36
+ content?: string | ContentBlock[]
37
+ usage?: TokenUsage
38
+ }
39
+ usage?: TokenUsage
40
+ // A message queued while CC was mid-turn: stored as type 'attachment' with
41
+ // the text under attachment.prompt (a string), never re-emitted as a user turn.
42
+ attachment?: { type?: string; prompt?: unknown; commandMode?: string }
43
+ }
44
+
45
+ interface ContentBlock {
46
+ type: string
47
+ text?: string
48
+ thinking?: string
49
+ id?: string
50
+ name?: string
51
+ input?: Record<string, unknown>
52
+ tool_use_id?: string
53
+ content?: unknown
54
+ is_error?: boolean
55
+ }
56
+
57
+ interface TokenUsage {
58
+ input_tokens?: number
59
+ output_tokens?: number
60
+ cache_read_input_tokens?: number
61
+ cache_creation_input_tokens?: number
62
+ }
63
+
64
+ type Event =
65
+ | { type: 'session'; sessionId: string }
66
+ | { type: 'user'; text: string }
67
+ | { type: 'assistant'; text: string; thinking: string; tools: { id: string; name: string; input: Record<string, unknown> }[]; live: boolean }
68
+ | { type: 'tool_result'; id: string; content: string; isError: boolean }
69
+ | { type: 'cleared' }
70
+ | { type: 'usage'; in: number; out: number; cached: number; cacheCreate: number }
71
+
72
+ // Mirror sessionStoragePortable.sanitizePath: non-alphanumeric → '-'.
73
+ // (CC also appends a hash for very long paths; short cwds don't need it.)
74
+ function sanitizePath(p: string): string {
75
+ return p.replace(/[^a-zA-Z0-9]/g, '-')
76
+ }
77
+
78
+ // Native only: transcripts come from the real ~/.claude. Everything the bulb
79
+ // writes itself (per-session locks) lives under <cwd>/.claude-bulb/.
80
+ const RUNTIME_DIR = '.claude-bulb'
81
+ const PROJECTS_DIR = join(homedir(), '.claude', 'projects')
82
+
83
+ const projectDir = (cwd: string) => join(PROJECTS_DIR, sanitizePath(cwd))
84
+
85
+ type SessionFile = { sessionId: string; file: string; mtime: number }
86
+
87
+ // The .jsonl session files CC has written for this cwd.
88
+ function sessionFiles(cwd: string): SessionFile[] {
89
+ const dir = projectDir(cwd)
90
+ if (!existsSync(dir)) return []
91
+ let entries: string[]
92
+ try { entries = readdirSync(dir) } catch { return [] }
93
+ const out: SessionFile[] = []
94
+ for (const name of entries) {
95
+ if (!name.endsWith('.jsonl')) continue
96
+ const file = join(dir, name)
97
+ try {
98
+ const s = statSync(file)
99
+ if (s.isFile()) out.push({ sessionId: name.slice(0, -'.jsonl'.length), file, mtime: s.mtimeMs })
100
+ } catch { /* races / permissions — skip */ }
101
+ }
102
+ return out
103
+ }
104
+
105
+ // Multi-bulb mutual exclusion: each bulb heartbeats a per-session lock file
106
+ // (mtime touched every poll) so two bulbs on the same cwd don't both attach to
107
+ // the newest .jsonl. Selection skips sessions with a live foreign-PID lock.
108
+ const LOCKS_DIR = `${RUNTIME_DIR}/locks`
109
+ const LOCK_TTL_MS = 5000 // claim valid for 5s of mtime staleness
110
+ const LOCK_SWEEP_AGE_MS = 60_000 // anything older than 1min on boot is junk
111
+
112
+ function locksDir(cwd: string): string {
113
+ return join(cwd, LOCKS_DIR)
114
+ }
115
+ function lockPath(cwd: string, sessionId: string): string {
116
+ return join(locksDir(cwd), `${sessionId}.lock`)
117
+ }
118
+ function isLockLive(cwd: string, sessionId: string): boolean {
119
+ try {
120
+ const p = lockPath(cwd, sessionId)
121
+ if (Date.now() - statSync(p).mtimeMs >= LOCK_TTL_MS) return false
122
+ // Our own PID doesn't block us: hot-reload wipes state but keeps the PID, so
123
+ // the prior lock is still "ours" — without this we'd skip our own session.
124
+ try {
125
+ const pid = parseInt(readFileSync(p, 'utf8').trim(), 10)
126
+ if (pid === process.pid) return false
127
+ } catch { /* unreadable lock body — fall through to "live" */ }
128
+ return true
129
+ } catch { return false } // missing → not live
130
+ }
131
+ // Synchronous, so we own the lock the instant we decide to attach.
132
+ function claimLock(cwd: string, sessionId: string) {
133
+ if (!sessionId) return
134
+ try {
135
+ mkdirSync(locksDir(cwd), { recursive: true })
136
+ writeFileSync(lockPath(cwd, sessionId), String(process.pid))
137
+ } catch (err: unknown) {
138
+ console.error('[lock] claim failed:', errorMessage(err))
139
+ }
140
+ }
141
+ // Boot housekeeping: drop dead lock files (liveness is the mtime check at runtime).
142
+ function sweepStaleLocks(cwd: string) {
143
+ const dir = locksDir(cwd)
144
+ if (!existsSync(dir)) return
145
+ let names: string[]
146
+ try { names = readdirSync(dir) } catch { return }
147
+ const now = Date.now()
148
+ for (const n of names) {
149
+ if (!n.endsWith('.lock')) continue
150
+ const p = join(dir, n)
151
+ try {
152
+ const stat = statSync(p)
153
+ if (now - stat.mtimeMs > LOCK_SWEEP_AGE_MS) unlinkSync(p)
154
+ } catch { /* races / permissions — skip */ }
155
+ }
156
+ }
157
+
158
+ function toText(content: unknown): string {
159
+ if (typeof content === 'string') return content
160
+ if (Array.isArray(content)) {
161
+ return content.map(b => (typeof b === 'string' ? b : (b as ContentBlock)?.text ?? (b as ContentBlock)?.content ?? JSON.stringify(b))).join('\n')
162
+ }
163
+ return content == null ? '' : JSON.stringify(content)
164
+ }
165
+
166
+ // The bulb never creates sessions — it only tails what the terminal writes.
167
+ // Attachment: fresh boot (nothing attached yet → auto-attach the newest
168
+ // unclaimed .jsonl) / attached (file present, heartbeating) / lost (the file
169
+ // vanished → stay put, never hop to a different live session). Invariant: file
170
+ // and sessionId are always both set or both empty.
171
+ interface State {
172
+ cwd: string
173
+ file?: string
174
+ sessionId: string
175
+ sessionStartMs: number // bulb start time; events newer than this are "live this session"
176
+ buffer: Event[]
177
+ partial: string // tail leftover (incomplete trailing line)
178
+ offset: number // last byte offset read from `file`
179
+ totals: { in: number; out: number; cached: number; cacheCreate: number }
180
+ everAttached: boolean // we've committed to ≥1 session; gates the fresh-boot auto-attach
181
+ // The JSONL is a tree (parentUuid); the live chain is the walk from the latest
182
+ // leaf to root. entries indexes uuid→entry; chainLastUuid is the last-emitted
183
+ // leaf, so a drain can tell extension (append) from divergence (rewind, re-emit).
184
+ entries: Map<string, JsonlEntry>
185
+ chainLastUuid?: string
186
+ }
187
+
188
+ // One live instance, constructed eagerly at module load so the rest of the module reads `state`
189
+ // directly with no undefined guard. The side-effecting boot (lock sweep + initial attach) runs at
190
+ // the very bottom of the module instead — it transitively reaches consts declared further down
191
+ // (e.g. INTERNAL_PATTERNS, via the attach drain), which are still in their TDZ up here.
192
+ const state: State = {
193
+ cwd: process.cwd(),
194
+ sessionId: '',
195
+ sessionStartMs: Date.now(),
196
+ buffer: [],
197
+ partial: '',
198
+ offset: 0,
199
+ totals: { in: 0, out: 0, cached: 0, cacheCreate: 0 },
200
+ everAttached: false,
201
+ entries: new Map(),
202
+ }
203
+
204
+ // existsSync collapses every error to "absent" — a transient Windows stat
205
+ // failure (sharing violation, AV/indexer lock) on a perfectly live file would
206
+ // then read as deletion. Only a confirmed ENOENT counts as gone; any other
207
+ // error keeps the current attachment.
208
+ function fileGone(file: string): boolean {
209
+ try { statSync(file); return false }
210
+ catch (err: unknown) { return (err as { code?: string }).code === 'ENOENT' }
211
+ }
212
+
213
+ // If attached and the file is still there, stay put and heartbeat the lock —
214
+ // never auto-flip to a newer sibling (that independence is the whole point, and
215
+ // it's also how two bulbs would otherwise hijack each other). Otherwise consult
216
+ // nextFileToAttach. Runs on every poll.
217
+ function refreshActive() {
218
+ const s = state
219
+ if (s.file && !fileGone(s.file)) {
220
+ claimLock(s.cwd, s.sessionId)
221
+ return
222
+ }
223
+ const found = nextFileToAttach(s)
224
+ if (found) attachTo(found)
225
+ }
226
+
227
+ function nextFileToAttach(s: State): SessionFile | undefined {
228
+ // Auto-attach ONLY on a fresh boot. Once we've ever attached, a vanished file
229
+ // means stay put — we never silently hop to a different live session, whether
230
+ // the attachment was boot-blank or picked.
231
+ if (s.everAttached) return undefined
232
+ // A hot reload (watch is on by default — every edit to this bulb reloads it)
233
+ // or a same-PID restart wipes `state` back to a fresh boot. Without this, boot
234
+ // re-picks the *newest* file, which is whichever sibling session most recently
235
+ // wrote — so a result landing in another session silently steals the view. Our
236
+ // prior incarnation still holds a freshly-heartbeated lock on the session we
237
+ // were pinned to, so resume that. A genuine relaunch is a new PID with no own
238
+ // lock and correctly falls through to the newest unclaimed pick.
239
+ return ownPinnedSession(s.cwd) ?? pickUnclaimedJsonl(s.cwd, () => true)
240
+ }
241
+
242
+ // The session this PID was pinned to before a state reset: the freshest lock
243
+ // whose body is our own PID and whose mtime is still within LOCK_TTL (so it's a
244
+ // live prior incarnation, not a stale leftover) and whose .jsonl still exists.
245
+ // undefined ⇒ no such pin (true fresh launch, or the pinned file is gone).
246
+ function ownPinnedSession(cwd: string): SessionFile | undefined {
247
+ const dir = locksDir(cwd)
248
+ if (!existsSync(dir)) return undefined
249
+ let names: string[]
250
+ try { names = readdirSync(dir) } catch { return undefined }
251
+ let best: { sessionId: string; mtime: number } | undefined
252
+ for (const n of names) {
253
+ if (!n.endsWith('.lock')) continue
254
+ try {
255
+ const st = statSync(join(dir, n))
256
+ if (Date.now() - st.mtimeMs >= LOCK_TTL_MS) continue // stale ⇒ not a live prior incarnation
257
+ if (parseInt(readFileSync(join(dir, n), 'utf8').trim(), 10) !== process.pid) continue
258
+ if (!best || st.mtimeMs > best.mtime) best = { sessionId: n.slice(0, -'.lock'.length), mtime: st.mtimeMs }
259
+ } catch { /* races / unreadable — skip */ }
260
+ }
261
+ if (!best) return undefined
262
+ const file = join(projectDir(cwd), `${best.sessionId}.jsonl`)
263
+ if (fileGone(file)) return undefined
264
+ return { sessionId: best.sessionId, file, mtime: best.mtime }
265
+ }
266
+
267
+ // Newest .jsonl with no live lock that also passes `extra`.
268
+ function pickUnclaimedJsonl(cwd: string, extra: (f: SessionFile) => boolean): SessionFile | undefined {
269
+ return sessionFiles(cwd)
270
+ .sort((a, b) => b.mtime - a.mtime)
271
+ .find(f => !isLockLive(cwd, f.sessionId) && extra(f))
272
+ }
273
+
274
+ // Single chokepoint for committing to a session file: resets per-session tail
275
+ // state and starts watching. Every path to ATTACHED funnels through here.
276
+ function attachTo(found: { sessionId: string; file: string }) {
277
+ const s = state
278
+ if (s.file) { try { unwatchFile(s.file) } catch {} }
279
+ s.file = found.file
280
+ s.sessionId = found.sessionId
281
+ s.everAttached = true
282
+ s.partial = ''
283
+ s.offset = 0
284
+ s.totals = { in: 0, out: 0, cached: 0, cacheCreate: 0 }
285
+ s.entries = new Map()
286
+ s.chainLastUuid = undefined
287
+ s.buffer.push({ type: 'cleared' })
288
+ s.buffer.push({ type: 'session', sessionId: found.sessionId })
289
+ claimLock(s.cwd, found.sessionId) // claim before the drain so siblings skip us
290
+ drainFile()
291
+ try { unwatchFile(found.file) } catch {} // drop a stale watcher from a prior hot-reload import (server.ts re-imports same-PID; else listeners pile up → MaxListeners leak)
292
+ watchFile(found.file, { interval: 200 }, () => drainFile())
293
+ }
294
+
295
+ function drainFile() {
296
+ const s = state
297
+ if (!s.file) return
298
+ let size: number
299
+ try { size = statSync(s.file).size } catch { return }
300
+ if (size <= s.offset) return
301
+ let fd: number
302
+ try { fd = openSync(s.file, 'r') } catch { return }
303
+ try {
304
+ const len = size - s.offset
305
+ const buf = Buffer.alloc(len)
306
+ let read = 0
307
+ while (read < len) {
308
+ const n = readSync(fd, buf, read, len - read, s.offset + read)
309
+ if (!n) break
310
+ read += n
311
+ }
312
+ s.offset += read
313
+ s.partial += buf.subarray(0, read).toString('utf8')
314
+ } finally { closeSync(fd) }
315
+ // Index the whole batch first (no events yet) so we can find the latest leaf,
316
+ // then chain-walk to decide extension vs divergence.
317
+ let latestUuid: string | undefined
318
+ let nl: number
319
+ while ((nl = s.partial.indexOf('\n')) >= 0) {
320
+ const line = s.partial.slice(0, nl)
321
+ s.partial = s.partial.slice(nl + 1)
322
+ if (!line.trim()) continue
323
+ let entry: JsonlEntry
324
+ try { entry = JSON.parse(line) } catch { continue }
325
+ if (!entry) continue
326
+ if (entry.uuid) {
327
+ s.entries.set(entry.uuid, entry)
328
+ // Sidechains (sub-agent threads) are their own chains — only main-thread
329
+ // entries can be the latest leaf.
330
+ if (!entry.isSidechain) latestUuid = entry.uuid
331
+ }
332
+ }
333
+ if (!latestUuid) return
334
+ // Walk parentUuid leaf→root. If we pass chainLastUuid, the new entries extend
335
+ // what we've emitted (common case). If not, a terminal /rewind forked off our
336
+ // leaf — clear and re-emit so the UI matches CC.
337
+ const walked: JsonlEntry[] = [] // leaf → root order
338
+ let cur: JsonlEntry | undefined = s.entries.get(latestUuid)
339
+ let foundPrev = false
340
+ while (cur) {
341
+ walked.push(cur)
342
+ if (cur.uuid === s.chainLastUuid) { foundPrev = true; break }
343
+ cur = cur.parentUuid ? s.entries.get(cur.parentUuid) : undefined
344
+ }
345
+ if (foundPrev) {
346
+ // walked[last] is the prior leaf, already emitted; the rest is new (root→leaf).
347
+ for (let i = walked.length - 2; i >= 0; i--) processEntry(walked[i])
348
+ } else {
349
+ // Divergence — clear so we don't show dead branches / double-count. Skip on
350
+ // the first drain after attach (attachTo already pushed a cleared).
351
+ if (s.chainLastUuid !== undefined) {
352
+ s.buffer.push({ type: 'cleared' })
353
+ s.buffer.push({ type: 'session', sessionId: s.sessionId })
354
+ s.totals = { in: 0, out: 0, cached: 0, cacheCreate: 0 }
355
+ }
356
+ for (let i = walked.length - 1; i >= 0; i--) processEntry(walked[i])
357
+ }
358
+ s.chainLastUuid = latestUuid
359
+ }
360
+
361
+ // Harness noise to hide from the chat. Status-line markers stay unanchored — they
362
+ // surface wrapped, e.g. "Error: …". The structural envelope tags anchor to the block
363
+ // start: a genuine injection opens with the tag, so a reply that merely quotes one
364
+ // mid-prose isn't dropped whole.
365
+ const INTERNAL_PATTERNS = [
366
+ /\[Request interrupted by user\b/i,
367
+ /\[Tool use was interrupted\]/i,
368
+ /Claude Code returned an error/i,
369
+ /\[ede_diagnostic\]/i,
370
+ /^No response requested\.?$/i,
371
+ /^<task-notification\b/i, // harness protocol envelopes, not human-facing
372
+ /^<system-reminder\b/i,
373
+ /^<ide_[a-z_]+>[\s\S]*<\/ide_[a-z_]+>$/i, // whole IDE-injected context block (opened-file, selection, …)
374
+ ]
375
+ function isInternal(text: string): boolean {
376
+ const t = text.trim()
377
+ return !!t && INTERNAL_PATTERNS.some(p => p.test(t))
378
+ }
379
+
380
+ // A real user-authored text block: a `type:'text'` block surviving isInternal — drops synthetic /
381
+ // IDE-context blocks (e.g. <ide_opened_file>) so they're neither rendered nor mistaken for the
382
+ // kickoff prompt. The string-content case (one whole block) is a plain isInternal check at the use
383
+ // site; this is the array shape, shared by applyEntry (the transcript) and readPreview (the picker).
384
+ function isUserTextBlock(b: ContentBlock | undefined): b is ContentBlock & { text: string } {
385
+ return !!b && b.type === 'text' && typeof b.text === 'string' && !!b.text && !isInternal(b.text)
386
+ }
387
+
388
+ // Guard the per-entry dispatch: it runs inside the watchFile callback, so an
389
+ // unhandled throw on one malformed entry would crash the whole node process
390
+ // (not just the turn). Skip the bad entry, keep draining the rest of the batch.
391
+ function processEntry(entry: JsonlEntry) {
392
+ try { applyEntry(entry) }
393
+ catch (err) { console.error('[claude-bulb] skipped malformed entry:', errorMessage(err)) }
394
+ }
395
+
396
+ function applyEntry(entry: JsonlEntry) {
397
+ if (entry.isSidechain) return // skip sub-agent threads in the same file
398
+ const s = state
399
+ if (entry.type === 'user') {
400
+ const content = entry.message?.content
401
+ if (typeof content === 'string') {
402
+ if (!isInternal(content)) {
403
+ s.buffer.push({ type: 'user', text: content })
404
+ }
405
+ } else if (Array.isArray(content)) {
406
+ for (const b of content) {
407
+ if (b?.type === 'tool_result') {
408
+ s.buffer.push({ type: 'tool_result', id: b.tool_use_id ?? '', content: toText(b.content), isError: !!b.is_error })
409
+ } else if (isUserTextBlock(b)) {
410
+ s.buffer.push({ type: 'user', text: b.text })
411
+ }
412
+ }
413
+ }
414
+ } else if (entry.type === 'attachment' && entry.attachment?.type === 'queued_command') {
415
+ // A message the user queued while CC was mid-turn. CC records it here, not as
416
+ // a user turn, and never re-emits it — so without this branch it's lost from
417
+ // the view. (Other attachment subtypes, e.g. 'selection', stay hidden.)
418
+ const text = toText(entry.attachment.prompt) // string in current CC; toText also tolerates older array shapes
419
+ if (text && !isInternal(text)) {
420
+ s.buffer.push({ type: 'user', text })
421
+ }
422
+ } else if (entry.type === 'assistant') {
423
+ const content = entry.message?.content
424
+ const blocks: ContentBlock[] = Array.isArray(content) ? content : []
425
+ const text = blocks
426
+ .filter(b => b.type === 'text')
427
+ .map(b => b.text ?? '')
428
+ .filter(t => !isInternal(t)) // drop synthetic / internal blocks
429
+ .join('')
430
+ const thinking = blocks.filter(b => b.type === 'thinking').map(b => b.thinking ?? '').join('\n')
431
+ const tools = blocks
432
+ .filter(b => b.type === 'tool_use')
433
+ .map(b => ({ id: b.id ?? '', name: b.name ?? '', input: b.input ?? {} }))
434
+ if (text || thinking || tools.length) {
435
+ // live = newer than bulb start; drives auto-expand of fresh edits.
436
+ const ts = Date.parse(entry.timestamp ?? '')
437
+ const live = !isNaN(ts) && ts >= s.sessionStartMs
438
+ s.buffer.push({ type: 'assistant', text, thinking, tools, live })
439
+ }
440
+ const usage = entry.message?.usage ?? entry.usage
441
+ if (usage) {
442
+ s.totals.in += usage.input_tokens ?? 0
443
+ s.totals.out += usage.output_tokens ?? 0
444
+ s.totals.cached += usage.cache_read_input_tokens ?? 0
445
+ s.totals.cacheCreate += usage.cache_creation_input_tokens ?? 0
446
+ s.buffer.push({ type: 'usage', ...s.totals })
447
+ }
448
+ }
449
+ // Ignore: mode, permission-mode, file-history-snapshot, system metadata, summary, etc.
450
+ }
451
+
452
+ // ---- exported RPC surface (callable from the browser as tb.server.<name>) ----
453
+
454
+ export async function info() {
455
+ const s = state
456
+ // pid lets the breakouts UI exclude this host's own running server from the list. (Excluding our
457
+ // own *file* row from the launcher is done in listBulbFiles, by content — see there.)
458
+ return { cwd: s.cwd, pid: process.pid }
459
+ }
460
+
461
+ export async function poll(cursor: number) {
462
+ const s = state
463
+ refreshActive()
464
+ return { events: s.buffer.slice(cursor), cursor: s.buffer.length, working: chainWorking(s) }
465
+ }
466
+
467
+ // "CC is mid-turn" — derived purely from the live chain, no timing heuristics.
468
+ // Judged from the last *conversational* entry, skipping CC's bookkeeping lines
469
+ // (queue-operation etc. carry a uuid and can briefly be the literal leaf). It's
470
+ // a user entry through the whole frozen-buffering window (CC flushes your prompt
471
+ // but not its own work yet — see the spec's flush note), and an assistant entry
472
+ // stays "working" while a tool_use has no matching tool_result. It goes idle
473
+ // only once an assistant entry has every tool resolved — the final answer landed.
474
+ function chainWorking(s: State): boolean {
475
+ let leaf: JsonlEntry | undefined // entries is insertion- (= file-) ordered
476
+ for (const e of s.entries.values()) if (e.type === 'user' || e.type === 'assistant') leaf = e
477
+ if (!leaf) return false
478
+ if (leaf.type === 'user') return true
479
+ const blocks = Array.isArray(leaf.message?.content) ? leaf.message!.content as ContentBlock[] : []
480
+ const toolUseIds = blocks.filter(b => b.type === 'tool_use').map(b => b.id)
481
+ if (toolUseIds.length === 0) return false
482
+ const resolved = new Set<string>()
483
+ for (const e of s.entries.values()) {
484
+ const c = e.message?.content
485
+ if (Array.isArray(c)) for (const b of c) if (b.type === 'tool_result' && b.tool_use_id) resolved.add(b.tool_use_id)
486
+ }
487
+ return toolUseIds.some(id => id && !resolved.has(id))
488
+ }
489
+
490
+ // ---- session picker ----
491
+
492
+ // The session's display title for the dropdown / tab. Mirrors Claude Code's own precedence: a user
493
+ // rename (`custom-title`) beats the Haiku-generated `ai-title`, which beats the kickoff user prompt.
494
+ // CC appends a fresh ai-title as the session evolves (never rewrites), so the latest wins — read the
495
+ // tail (most recent) before the head, then fall back to the first user prompt. Read-only: CC writes
496
+ // these entries; we only prefer reading them — no inference here (Invariant 1).
497
+ function readPreview(file: string): string {
498
+ let fd: number
499
+ try { fd = openSync(file, 'r') } catch { return '' }
500
+ try {
501
+ const CAP = 64 * 1024
502
+ let size = CAP
503
+ try { size = statSync(file).size } catch {}
504
+ const readChunk = (pos: number, len: number): string => {
505
+ if (len <= 0) return ''
506
+ const buf = Buffer.alloc(len)
507
+ const n = readSync(fd, buf, 0, len, pos)
508
+ return buf.subarray(0, n).toString('utf8')
509
+ }
510
+ // Latest non-empty title of `type` in a chunk (last wins, since CC only appends). The substring
511
+ // pre-check skips JSON.parse on the message lines that dominate the window.
512
+ const lastTitle = (text: string, type: string, field: 'aiTitle' | 'customTitle'): string => {
513
+ let found = ''
514
+ for (const line of text.split('\n')) {
515
+ if (!line.includes(`"${type}"`)) continue
516
+ let e: JsonlEntry
517
+ try { e = JSON.parse(line) } catch { continue }
518
+ const v = e?.[field]
519
+ if (e?.type === type && typeof v === 'string' && v) found = v
520
+ }
521
+ return found
522
+ }
523
+ // Head holds the kickoff prompt + an early title; the tail holds the most recent title (it can
524
+ // sit past the head in a long session). The tail read is skipped when the file fits one window.
525
+ const head = readChunk(0, Math.min(CAP, size))
526
+ const tail = size > CAP ? readChunk(size - CAP, CAP) : ''
527
+ const title =
528
+ lastTitle(tail, 'custom-title', 'customTitle') || lastTitle(head, 'custom-title', 'customTitle') ||
529
+ lastTitle(tail, 'ai-title', 'aiTitle') || lastTitle(head, 'ai-title', 'aiTitle')
530
+ if (title) return title.replace(/\s+/g, ' ').trim().slice(0, 200)
531
+ // No title yet (a short or older session): fall back to the first real user turn from the head.
532
+ for (const line of head.split('\n')) {
533
+ if (!line.trim()) continue
534
+ let e: JsonlEntry
535
+ try { e = JSON.parse(line) } catch { continue }
536
+ if (!e || e.isSidechain || e.type !== 'user') continue
537
+ const content = e.message?.content
538
+ let extracted = ''
539
+ if (typeof content === 'string') {
540
+ if (!isInternal(content)) extracted = content
541
+ } else if (Array.isArray(content)) {
542
+ // Concatenate the real user-text blocks; isUserTextBlock drops synthetic/IDE-context ones so
543
+ // an "<ide_opened_file>" entry isn't mistaken for the kickoff prompt — we fall through to it.
544
+ extracted = content.filter(isUserTextBlock).map(b => b.text).join(' ')
545
+ }
546
+ const cleaned = extracted.replace(/\s+/g, ' ').trim()
547
+ // Strip the fixed reincarnation lead-in so those sessions surface their distinctive summary.
548
+ if (cleaned) {
549
+ const stripped = cleaned.replace(/^Continuing from a prior session\. Here is the summary of our work so far:\s*/i, '').trim()
550
+ return (stripped || cleaned).slice(0, 200)
551
+ }
552
+ }
553
+ return ''
554
+ } finally { closeSync(fd) }
555
+ }
556
+
557
+ export async function listSessions() {
558
+ return sessionFiles(state.cwd)
559
+ .sort((a, b) => b.mtime - a.mtime)
560
+ .map(({ sessionId, file, mtime }) => ({ sessionId, mtime, preview: readPreview(file) }))
561
+ }
562
+
563
+ export async function attach(sessionId: string) {
564
+ const s = state
565
+ const file = join(projectDir(s.cwd), `${sessionId}.jsonl`)
566
+ if (!existsSync(file)) return { ok: false, error: 'session not found' }
567
+ if (file === s.file) return { ok: true }
568
+ attachTo({ sessionId, file })
569
+ return { ok: true }
570
+ }
571
+
572
+ // ---- editor integration ----
573
+
574
+ export async function openFile(filePath: string, line?: number) {
575
+ // shell:true so Windows resolves `code.cmd`; detached+unref so the editor
576
+ // doesn't tie to the bulb's lifetime. `-g file:line` jumps to a cited line.
577
+ const args = line ? ['-g', `${filePath}:${line}`] : [filePath]
578
+ const proc = spawn('code', args, { shell: true, detached: true, stdio: 'ignore' })
579
+ proc.on('error', err => console.error('[claude-bulb] code launch failed:', err?.message ?? err))
580
+ proc.unref()
581
+ return { ok: true }
582
+ }
583
+
584
+ // ---- breakout: promote an embedded bulb to a standalone file + launch it ----
585
+
586
+ // The filename comes from the bulb's own `name:` frontmatter, slugified (slugifyBulbName,
587
+ // the typebulb capability) — no prompt, no guess. The host owns only *where* the file lands.
588
+ export async function breakout(source: string) {
589
+ const cwd = state.cwd
590
+ // Bulbs land in a `typebulbs/` folder (created on demand), keeping the project
591
+ // root clean and mirroring the repo's own convention.
592
+ const dir = join(cwd, 'typebulbs')
593
+ mkdirSync(dir, { recursive: true })
594
+ const slug = slugifyBulbName(source)
595
+ // Never clobber a file we didn't write: an identical existing file is reused
596
+ // (idempotent relaunch); a name clash with different content takes the next -N.
597
+ let file = `${slug}.bulb.md`
598
+ for (let n = 2; existsSync(join(dir, file)) && readFileSync(join(dir, file), 'utf8') !== source; n++) {
599
+ file = `${slug}-${n}.bulb.md`
600
+ }
601
+ const path = join(dir, file)
602
+ if (!existsSync(path)) writeFileSync(path, source)
603
+ const rel = join('typebulbs', file)
604
+ // The launch itself — cross-platform detached spawn, idempotency (one server per file,
605
+ // so a repeated breakout re-attaches instead of double-spawning), and registration so
606
+ // listBreakouts/stopBreakout can find and stop it — is the typebulb capability. The host
607
+ // owns only the file policy above. cwd = project root so the bulb resolves its rel path.
608
+ const server = await launchBulbServer(rel, { cwd, open: true })
609
+ return { ok: true, file: rel, pid: server.pid, url: server.url }
610
+ }
611
+
612
+ // The running bulb dev servers for the status-bar pill — scoped to *this project* (state.cwd), the
613
+ // same project whose sessions and files we mirror. The registry is machine-global, but a viewer for
614
+ // one CC project shouldn't surface another project's bulbs (or another project's claude.bulb); the
615
+ // cwd scope mirrors the file walk and keeps projects from bleeding into each other. listBulbServers
616
+ // prunes dead entries on read; the UI still drops this host's own pid (see info()).
617
+ export async function listBreakouts() {
618
+ return listBulbServers(state.cwd)
619
+ }
620
+
621
+ // Stop one by pid (SIGTERM + deregister). Idempotent — an already-gone pid is a no-op.
622
+ export async function stopBreakout(pid: number) {
623
+ await stopBulbServer(pid)
624
+ return { ok: true }
625
+ }
626
+
627
+ // ---- launcher: the project's .bulb.md files, launchable from the status bar ----
628
+
629
+ // ---- per-bulb trust memory ----
630
+ //
631
+ // Trust memory now lives in the CLI's store (isBulbTrusted/setBulbTrusted from typebulb/servers),
632
+ // not here — so the launcher toggle, a bare `typebulb <file>`, and `typebulb trust` all share one
633
+ // source of truth (Specs/Typebulb-CLI-Trust.md). The launcher just reads/writes that store; it no
634
+ // longer keeps its own `.claude-bulb/trust.json`.
635
+
636
+ // The project's *.bulb.md candidates for the launcher. The walk + name extraction is the typebulb
637
+ // capability (listProjectBulbFiles); the host overlays each bulb's remembered tier from the store.
638
+ //
639
+ // We drop the mirror itself and any copy of it (the monorepo carries a built dist/ snapshot beside
640
+ // the source, and the walk finds both) by matching our own frontmatter *name*. Name beats path or
641
+ // content here: it's stale-proof (survives source edits the dist/ copy hasn't caught up to) and is
642
+ // exactly what the walk already extracts. The CLI hands us our path in TYPEBULB_BULB_PATH; bulbName
643
+ // reads our title from it. A user's own, differently-named bulb still lists.
644
+ export async function listBulbFiles() {
645
+ const cwd = state.cwd
646
+ const self = process.env.TYPEBULB_BULB_PATH
647
+ let selfName: string | undefined
648
+ try { if (self) selfName = bulbName(readFileSync(self, 'utf8').slice(0, 1024)) } catch { /* unnamed/unreadable ⇒ exclude nothing */ }
649
+ return listProjectBulbFiles(cwd)
650
+ .filter(f => !selfName || f.name !== selfName)
651
+ .map(f => ({ ...f, trusted: isBulbTrusted(f.path) }))
652
+ }
653
+
654
+ // Launch (or re-attach to) a dev server for an existing project bulb — the same node capability
655
+ // breakout uses, minus the file write. Idempotent per file. `trust` is the explicit choice (the
656
+ // elevation modal / toggle); passing it persists the decision to the CLI store. When omitted, the
657
+ // spawned server resolves the remembered tier itself (its main() consults the same store) — so the
658
+ // effective-tier decision lives in one place; we just report the tier it actually came up in.
659
+ export async function launchBulb(file: string, trust?: boolean) {
660
+ const cwd = state.cwd
661
+ if (trust !== undefined) setBulbTrusted(file, trust) // explicit decision persists
662
+ const server = await launchBulbServer(file, { cwd, open: true, trust })
663
+ return { ok: true, file: server.file, pid: server.pid, url: server.url, trust: !!server.trust }
664
+ }
665
+
666
+ // Scan a bulb for privileged tb.* usage BEFORE launching, so the launcher can raise the trust
667
+ // offer at the decision point rather than after the spawned server registers (Specs/Typebulb-CLI-
668
+ // Trust.md "Proactive prediction"). Returns a capability label or undefined; a hint, never a gate.
669
+ export async function predictTrustOf(file: string) {
670
+ return { cap: await predictBulbTrust(file) }
671
+ }
672
+
673
+ // Set (or clear) a bulb's remembered trust decision (the launcher's trust toggle) — delegates to the
674
+ // CLI's shared store. Writing the memory only takes effect on the next launch; a running server's tier
675
+ // is fixed at process start, so to apply it now the launcher stops and relaunches that server itself.
676
+ export async function setBulbTrust(file: string, trust: boolean) {
677
+ setBulbTrusted(file, trust)
678
+ return { ok: true }
679
+ }
680
+
681
+ // Tail a running server's console (its `<pid>.log`). New bytes since `offset`; the server
682
+ // writes the file, any host reads it — the same drain-from-offset shape as the transcript.
683
+ export async function readBulbLog(pid: number, offset: number) {
684
+ return readServerLog(pid, offset)
685
+ }
686
+
687
+ // Boot housekeeping, run once after the whole module is initialised (so the attach drain can reach
688
+ // every const it needs): clear crashed-bulb lock claims, then attach to the newest session. A hot
689
+ // reload re-imports the module and re-runs this fresh boot (see nextFileToAttach for what it picks).
690
+ sweepStaleLocks(state.cwd)
691
+ refreshActive()
692
+ ```
693
+
694
+ **code.tsx**
695
+
696
+ ```tsx
697
+ import MarkdownIt from 'markdown-it'
698
+ import katex from 'katex'
699
+ import DOMPurify from 'dompurify'
700
+ import { renderMermaidSVG } from 'beautiful-mermaid'
701
+ import { createBulbFrame, stripFrontmatter } from 'typebulb'
702
+ import hljs from 'highlight.js/lib/core'
703
+ import hljsTypescript from 'highlight.js/lib/languages/typescript'
704
+ import hljsXml from 'highlight.js/lib/languages/xml'
705
+ import hljsCss from 'highlight.js/lib/languages/css'
706
+ import hljsJson from 'highlight.js/lib/languages/json'
707
+ import hljsMarkdown from 'highlight.js/lib/languages/markdown'
708
+ import hljsPlaintext from 'highlight.js/lib/languages/plaintext'
709
+ import {
710
+ App, Component, div, span, a, button, pre, details, summary, inputText, svg, path, rect,
711
+ } from 'domeleon'
712
+
713
+ const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
714
+
715
+ // Syntax highlighting for the code view: a custom hljs core build registering only
716
+ // the grammars a bulb actually uses — keeps the dep to ~tens of KB vs hljs's full
717
+ // ~1MB bundle. All imports are bare specifiers, so they resolve through typebulb's
718
+ // import map and the CLI's disk-cached proxy; nothing hits esm.sh directly. No hljs
719
+ // theme stylesheet either — token colors come from our own CSS vars (.bulb-code
720
+ // .hljs-*) so they follow the host light/dark theme instead of baking a palette.
721
+ hljs.registerLanguage('typescript', hljsTypescript)
722
+ hljs.registerLanguage('xml', hljsXml)
723
+ hljs.registerLanguage('css', hljsCss)
724
+ hljs.registerLanguage('json', hljsJson)
725
+ hljs.registerLanguage('markdown', hljsMarkdown)
726
+ hljs.registerLanguage('plaintext', hljsPlaintext)
727
+
728
+ // Fence tag → registered grammar. tsx/ts fold into typescript (covers the TS
729
+ // superset; JSX is close enough for a read view), html into xml. Anything unknown
730
+ // or absent falls back to escaped plaintext (always registered, so never throws).
731
+ const HLJS_LANG: Record<string, string> = {
732
+ ts: 'typescript', tsx: 'typescript', typescript: 'typescript',
733
+ html: 'xml', xml: 'xml',
734
+ css: 'css', json: 'json',
735
+ md: 'markdown', markdown: 'markdown',
736
+ text: 'plaintext', txt: 'plaintext', plaintext: 'plaintext',
737
+ }
738
+ function highlightCode(code: string, lang: string): string {
739
+ const name = HLJS_LANG[(lang || '').toLowerCase()] ?? 'plaintext'
740
+ return `<pre class="hljs"><code>${hljs.highlight(code, { language: name }).value}</code></pre>`
741
+ }
742
+
743
+ // Plain markdown for the code view: renders a bulb's .bulb.md source (file labels +
744
+ // fenced code) with NONE of md's custom fence rules — crucially not the ````bulb````
745
+ // rule, which would mount a live sub-bulb inside a *code* view (bulb-in-bulb is real).
746
+ // Defaults plus the hljs highlight hook above.
747
+ const mdPlain = new MarkdownIt({ html: false, linkify: false, breaks: false, highlight: highlightCode })
748
+
749
+ // markdown-it v14 ships no type declarations, so rather than `any` everywhere we type just the
750
+ // renderer surface our rules touch: the token fields/methods they read, the inline-state methods
751
+ // mathRule drives, and the renderer's renderToken. `env`/`opts` stay opaque (`unknown`) — the rules
752
+ // only ever pass them through, never inspect them.
753
+ interface MdToken { info?: string; content: string; markup?: string; meta?: { display?: boolean }; attrSet(name: string, value: string): void }
754
+ interface MdRenderer { renderToken(tokens: MdToken[], idx: number, opts: unknown): string }
755
+ interface MdInlineState { src: string; pos: number; push(type: string, tag: string, nesting: number): MdToken }
756
+ type MdRenderRule = (tokens: MdToken[], idx: number, opts: unknown, env: unknown, self: MdRenderer) => string
757
+
758
+ // Links open in a new tab: the bulb is a dashboard, in-place nav would lose
759
+ // scroll / session. rel=noopener is standard tab-napping hygiene.
760
+ const defaultLinkOpen: MdRenderRule = md.renderer.rules.link_open
761
+ ?? ((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts))
762
+ md.renderer.rules.link_open = (tokens: MdToken[], idx: number, opts: unknown, env: unknown, self: MdRenderer) => {
763
+ tokens[idx].attrSet('target', '_blank')
764
+ tokens[idx].attrSet('rel', 'noopener noreferrer')
765
+ return defaultLinkOpen(tokens, idx, opts, env, self)
766
+ }
767
+
768
+ // Math is a markdown-it inline rule registered BEFORE `escape` — it must claim
769
+ // the raw TeX before markdown-it strips the \( \) \[ \] backslashes or mangles
770
+ // `\\` / `_` / `*` inside the formula. Code/fenced spans are already tokenized, so
771
+ // math inside them is skipped for free.
772
+ const MATH_DELIMS = [
773
+ { open: '$$', close: '$$', display: true },
774
+ { open: '$', close: '$', display: false },
775
+ { open: '\\[', close: '\\]', display: true },
776
+ { open: '\\(', close: '\\)', display: false },
777
+ ]
778
+
779
+ function mathRule(state: MdInlineState, silent: boolean): boolean {
780
+ const src = state.src
781
+ const start = state.pos
782
+ for (const d of MATH_DELIMS) {
783
+ if (!src.startsWith(d.open, start)) continue
784
+ const from = start + d.open.length
785
+ const to = src.indexOf(d.close, from)
786
+ if (to < 0) continue
787
+ const content = src.slice(from, to)
788
+ if (!content.trim()) continue
789
+ // Bare-`$` currency guard: skip "$5 and $6" — opener not followed by space,
790
+ // closer not preceded by space, and no digit immediately after the close.
791
+ if (d.open === '$' &&
792
+ (/\s/.test(src[from] ?? '') || /\s/.test(src[to - 1] ?? '') || /\d/.test(src[to + 1] ?? ''))) continue
793
+ if (!silent) {
794
+ const token = state.push('math', 'span', 0)
795
+ token.content = content
796
+ token.markup = d.open
797
+ token.meta = { display: d.display }
798
+ }
799
+ state.pos = to + d.close.length
800
+ return true
801
+ }
802
+ return false
803
+ }
804
+ md.inline.ruler.before('escape', 'math', mathRule)
805
+ md.renderer.rules.math = (tokens: MdToken[], idx: number) => {
806
+ const t = tokens[idx]
807
+ try { return katex.renderToString(t.content, { displayMode: !!t.meta?.display, throwOnError: false }) }
808
+ catch { return md.utils.escapeHtml(t.markup + t.content + t.markup) }
809
+ }
810
+
811
+ // beautiful-mermaid decodes only XML + numeric entities (decodeXML), so named HTML
812
+ // entities — &nbsp;, &mdash;, … — survive undecoded and then render as literal
813
+ // text. Decode the full named set first; the library's pass is then a no-op.
814
+ function decodeHtmlEntities(s: string): string {
815
+ const ta = document.createElement('textarea')
816
+ ta.innerHTML = s
817
+ return ta.value
818
+ }
819
+
820
+ // ```mermaid``` fences render to inline SVG. A parse error falls through to the
821
+ // default code-block rendering, so an unsupported diagram degrades to readable
822
+ // source rather than a broken message.
823
+ const defaultFence: MdRenderRule = md.renderer.rules.fence
824
+ ?? ((tokens, idx, opts, _env, self) => self.renderToken(tokens, idx, opts))
825
+ md.renderer.rules.fence = (tokens: MdToken[], idx: number, opts: unknown, env: unknown, self: MdRenderer) => {
826
+ const t = tokens[idx]
827
+ const lang = (t.info ?? '').trim().toLowerCase()
828
+ // Note: no `bulb` case here. Live embeds are split out of the text before markdown runs
829
+ // (splitBulbSegments → BulbEmbed), so a ````bulb```` fence reaching markdown is illustrative
830
+ // source — it falls through to defaultFence like any other unrecognised fence.
831
+ // ```svg``` fences embed raw SVG. Same trust level as the rest of the assistant
832
+ // markdown we render, but raw SVG can carry <script>/onload/<foreignObject>, so
833
+ // it goes through DOMPurify's svg profile first — geometry survives, the script
834
+ // surface is stripped. Lets the agent draw anything (smiley, plot from an
835
+ // equation) without an iframe, since it's static markup, not executed code.
836
+ if (lang === 'svg') {
837
+ return `<div class="svg-embed">${DOMPurify.sanitize(t.content, { USE_PROFILES: { svg: true, svgFilters: true } })}</div>`
838
+ }
839
+ if (lang === 'mermaid') {
840
+ try {
841
+ // The library's internal theme vars share names with the bulb's (--fg/--bg/
842
+ // --muted/--border/--accent), so passing var(--fg) here would emit a
843
+ // self-referential `--fg: var(--fg)` on the SVG — a CSS cycle that resolves
844
+ // to invalid. The --mm-* aliases (styles.css) break the cycle, still themed.
845
+ const svg = renderMermaidSVG(decodeHtmlEntities(t.content), {
846
+ // bg backs the label mask-rects (edge/loop labels); without it they default
847
+ // to white and wash out in dark mode. transparent keeps the canvas clear.
848
+ bg: 'var(--mm-bg)',
849
+ fg: 'var(--mm-fg)',
850
+ line: 'var(--mm-line)',
851
+ accent: 'var(--mm-accent)',
852
+ muted: 'var(--mm-muted)',
853
+ surface: 'var(--mm-surface)',
854
+ border: 'var(--mm-border)',
855
+ font: 'system-ui, -apple-system, "Segoe UI", sans-serif',
856
+ transparent: true,
857
+ })
858
+ return `<div class="mermaid">${svg}</div>`
859
+ } catch { /* fall through to a plain code block */ }
860
+ }
861
+ return defaultFence(tokens, idx, opts, env, self)
862
+ }
863
+
864
+ // Author classDef/style colors render as literal fill/stroke/color that override
865
+ // the theme, freezing a light-mode palette into dark mode. The library has no flag
866
+ // to suppress this, and its prop parser splits on every comma, so a color-mix() fed
867
+ // through the source is shredded — we have to blend post-render instead. The
868
+ // library's own colors are var(--_…), which the literal-color test skips.
869
+ const isLiteralColor = (v: string | null): v is string =>
870
+ !!v && !v.startsWith('var(') && !v.startsWith('url(') && v !== 'none' && v !== 'transparent'
871
+
872
+ function themeMermaidNodes(root: Element) {
873
+ for (const el of root.querySelectorAll<SVGElement>('.mermaid g.node [fill], .mermaid g.node [stroke]')) {
874
+ const fill = el.getAttribute('fill')
875
+ if (isLiteralColor(fill)) {
876
+ // Text → themed fg for legibility; shape → keep the hue, mixed mostly into bg.
877
+ el.setAttribute('fill', el.tagName.toLowerCase() === 'text'
878
+ ? 'var(--mm-fg)'
879
+ : `color-mix(in srgb, ${fill} 25%, var(--mm-bg))`)
880
+ }
881
+ const stroke = el.getAttribute('stroke')
882
+ if (isLiteralColor(stroke)) {
883
+ el.setAttribute('stroke', `color-mix(in srgb, ${stroke} 55%, var(--mm-fg))`)
884
+ }
885
+ }
886
+ }
887
+
888
+ // A relative-path link in assistant markdown is a local file citation (optionally
889
+ // with a #Lnnn line anchor): open it in the editor, not the browser — the bulb is
890
+ // served from the project root, so the browser would GET the path and 404. Links
891
+ // with a scheme (http, mailto, …) fall through to the default new-tab behavior.
892
+ function onMarkdownClick(e: Event) {
893
+ const anchor = (e.target as Element | null)?.closest('a')
894
+ if (!anchor) return
895
+ const href = anchor.getAttribute('href') ?? ''
896
+ if (!href || href.includes('://') || href.startsWith('//') || href.startsWith('#') || /^(mailto|tel):/i.test(href)) return
897
+ e.preventDefault()
898
+ const hashAt = href.indexOf('#')
899
+ const path = hashAt >= 0 ? href.slice(0, hashAt) : href
900
+ const lineMatch = hashAt >= 0 ? /^L(\d+)/.exec(href.slice(hashAt + 1)) : null
901
+ tb.server.openFile(decodeURIComponent(path), lineMatch ? parseInt(lineMatch[1], 10) : undefined)
902
+ }
903
+
904
+ // Render markdown into the element via innerHTML. The click handler is bound natively here, not via
905
+ // domeleon's `onClick`: the anchors are raw innerHTML the vdom never sees, so a delegated handler
906
+ // wouldn't fire. onMarkdownClick is a stable ref, so re-mounts don't stack duplicate listeners.
907
+ const renderMarkdown = (text: string) => (el: Element) => {
908
+ try {
909
+ el.innerHTML = md.render(text)
910
+ themeMermaidNodes(el)
911
+ } catch {
912
+ ;(el as HTMLElement).textContent = text
913
+ }
914
+ el.addEventListener('click', onMarkdownClick)
915
+ }
916
+
917
+ // A user prompt's @mentions become clickable links. The lookbehind requires a word
918
+ // boundary before the @ so an email's @ (foo@bar.com) isn't mistaken for a mention.
919
+ // `:` is in the class so Windows drive paths (@C:\dir\file) match instead of stopping
920
+ // at the colon. atMentionLink then routes each match.
921
+ const AT_MENTION = /(?<=^|\s)@[\w./\\:-]+/g
922
+
923
+ // A path separator or dot means file citation (relative-path link → editor, via
924
+ // onMarkdownClick). A bare word matching X's handle grammar ([A-Za-z0-9_], ≤15) goes
925
+ // to x.com — for this tool's users a plain @name is far more often a handle than an
926
+ // extensionless filename. The ://-bearing x.com link falls through to the new-tab rule.
927
+ const atMentionLink = (m: string): string => {
928
+ const body = m.slice(1)
929
+ if (/^[A-Za-z0-9_]{1,15}$/.test(body)) return `[${m}](https://x.com/${body})`
930
+ return `[${m}](${body})`
931
+ }
932
+
933
+ // User prompts render as markdown, same as the assistant. Two pre-passes first:
934
+ // @mentions are rewritten to links (see atMentionLink), and merged consecutive sends
935
+ // (see applyUser) are joined by a thematic break so the bubble still reads as one turn.
936
+ const userMarkdown = (msg: Msg) => {
937
+ const segs = msg.segments ?? [msg.text]
938
+ const src = segs.join('\n\n---\n\n').replace(AT_MENTION, atMentionLink)
939
+ return renderMarkdown(src)
940
+ }
941
+
942
+ // Split an assistant message into ordered markdown chunks and live ````bulb```` sources, using
943
+ // markdown-it's own parser (md.parse) rather than a hand-rolled fence regex — the same tokenizer
944
+ // that renders the prose, so nested ``` fences and fence-length rules are handled for free. A
945
+ // `fence` token tagged `bulb` carries its body in `token.content`; `token.map` gives its line span
946
+ // so the surrounding prose slices out cleanly. The caller turns each bulb source into a BulbEmbed.
947
+ type BulbSegment = { kind: 'md'; text: string } | { kind: 'bulb'; source: string }
948
+ function splitBulbSegments(text: string): BulbSegment[] {
949
+ const lines = text.split('\n')
950
+ const segs: BulbSegment[] = []
951
+ let cursor = 0
952
+ for (const t of md.parse(text, {})) {
953
+ if (t.type === 'fence' && (t.info ?? '').trim().toLowerCase() === 'bulb' && t.map) {
954
+ const [start, end] = t.map
955
+ if (start > cursor) segs.push({ kind: 'md', text: lines.slice(cursor, start).join('\n') })
956
+ segs.push({ kind: 'bulb', source: t.content })
957
+ cursor = end
958
+ }
959
+ }
960
+ if (cursor < lines.length) segs.push({ kind: 'md', text: lines.slice(cursor).join('\n') })
961
+ return segs
962
+ }
963
+
964
+ // Mirror of the server's Event union (the RPC boundary is untyped). Keep in sync.
965
+ type ServerEvent =
966
+ | { type: 'session'; sessionId: string }
967
+ | { type: 'user'; text: string }
968
+ | { type: 'assistant'; text: string; thinking: string; tools: { id: string; name: string; input: Record<string, unknown> }[]; live: boolean }
969
+ | { type: 'tool_result'; id: string; content: string; isError: boolean }
970
+ | { type: 'cleared' }
971
+ | { type: 'usage'; in: number; out: number; cached: number; cacheCreate: number }
972
+
973
+ interface Tool { id: string; name: string; input: Record<string, unknown>; result?: string; isError: boolean }
974
+ // `segments` is set only when consecutive user sends are merged into one bubble. `body` is set
975
+ // only when an assistant message contains live ````bulb```` embeds: the text split into markdown
976
+ // chunks (string) and BulbEmbed components, rendered in order in place of the single markdown div.
977
+ interface Msg { id: number; role: 'user' | 'assistant'; text: string; thinking: string; tools: Tool[]; copy?: CopyButton; segments?: string[]; body?: (string | BulbEmbed)[] }
978
+
979
+ // Tool inputs are heterogeneous JSON; narrow to string at the point of use.
980
+ const asStr = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
981
+
982
+ function toolSummary(input: Record<string, unknown>): string {
983
+ if (!input || typeof input !== 'object') return ''
984
+ return asStr(input.command) ?? asStr(input.file_path) ?? asStr(input.path) ?? asStr(input.pattern) ?? asStr(input.query) ?? asStr(input.url) ?? asStr(input.skill) ?? asStr(input.description) ?? ''
985
+ }
986
+
987
+ // File-aware tools whose path should open in VS Code / Cursor on click.
988
+ const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Glob', 'Grep'])
989
+
990
+ // Edit tools whose diff is interesting enough to auto-expand in the live session.
991
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
992
+
993
+ function filePathOf(toolName: string, input: Record<string, unknown>): string | undefined {
994
+ if (!FILE_TOOLS.has(toolName) || !input || typeof input !== 'object') return undefined
995
+ return (asStr(input.file_path) ?? asStr(input.path)) || undefined
996
+ }
997
+
998
+ // Normalize each edit tool's input to (old → new) hunks. undefined = no diff view.
999
+ type Hunk = { old?: string; new?: string }
1000
+ function diffHunks(t: Tool): Hunk[] | undefined {
1001
+ const i = t.input ?? {}
1002
+ switch (t.name) {
1003
+ case 'Edit': return [{ old: asStr(i.old_string), new: asStr(i.new_string) }]
1004
+ case 'Write': return [{ new: asStr(i.content) }]
1005
+ case 'NotebookEdit': return [{ new: asStr(i.new_source) }]
1006
+ case 'MultiEdit': return Array.isArray(i.edits)
1007
+ ? i.edits.map((e: { old_string?: string; new_string?: string }) => ({ old: e?.old_string, new: e?.new_string }))
1008
+ : undefined
1009
+ default: return undefined
1010
+ }
1011
+ }
1012
+
1013
+ const TURN_PALETTE_SIZE = 5
1014
+ const turnClassFor = (i: number) => `turn-${i % TURN_PALETTE_SIZE}`
1015
+
1016
+ // One-line summary of a turn's collapsed (intermediate) bubbles: step count +
1017
+ // tool tally, e.g. "5 steps · Read, Edit ×2, Bash". Pure data, no inference.
1018
+ function summarizeSteps(msgs: Msg[]): string {
1019
+ const counts = new Map<string, number>()
1020
+ for (const m of msgs) for (const t of m.tools) counts.set(t.name, (counts.get(t.name) ?? 0) + 1)
1021
+ const tally = [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(', ')
1022
+ const n = msgs.length
1023
+ return `${n} step${n === 1 ? '' : 's'}${tally ? ` · ${tally}` : ''}`
1024
+ }
1025
+
1026
+ function relTime(ms: number): string {
1027
+ const d = Math.max(0, Date.now() - ms)
1028
+ if (d < 60_000) return 'now'
1029
+ if (d < 3_600_000) return `${Math.floor(d / 60_000)}m`
1030
+ if (d < 86_400_000) return `${Math.floor(d / 3_600_000)}h`
1031
+ if (d < 7 * 86_400_000) return `${Math.floor(d / 86_400_000)}d`
1032
+ return new Date(ms).toLocaleDateString()
1033
+ }
1034
+
1035
+ function formatTokens(n: number): string {
1036
+ if (n < 1000) return `${n}`
1037
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
1038
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`
1039
+ return `${(n / 1_000_000).toFixed(1)}M`
1040
+ }
1041
+
1042
+ function truncate(s: string, max: number): string {
1043
+ return s.length > max ? s.slice(0, max).trimEnd() + '…' : s
1044
+ }
1045
+
1046
+ // Shared filter input + clear (×) for the bulb launcher and the session picker — one copy of the
1047
+ // filter chrome (the bulb-local analogue of client's uiHelpers/searchBox; the bulb can't import
1048
+ // that). The host owns the value (it drives the filtered list + highlight) and the key handling
1049
+ // (Enter/highlight semantics differ per menu), so those come in as props.
1050
+ function searchFilter(opts: {
1051
+ target: Component
1052
+ prop: () => string
1053
+ id: string
1054
+ placeholder: string
1055
+ hasValue: boolean
1056
+ onKeyDown: (e: KeyboardEvent) => void
1057
+ onClear: () => void
1058
+ }) {
1059
+ return div({ class: 'bulb-filter-control' },
1060
+ inputText({
1061
+ target: opts.target,
1062
+ prop: opts.prop,
1063
+ id: opts.id,
1064
+ // Keydown (not keyup) so arrows can preventDefault and repeat; the host decides what each does.
1065
+ attrs: { class: 'bulb-filter', placeholder: opts.placeholder, ariaLabel: opts.placeholder, onKeyDown: opts.onKeyDown },
1066
+ }),
1067
+ opts.hasValue
1068
+ ? button({ class: 'bulb-filter-clear', type: 'button', title: 'Clear filter', ariaLabel: 'Clear filter',
1069
+ onClick: (e: MouseEvent) => { e.stopPropagation(); opts.onClear() } }, '×')
1070
+ : null,
1071
+ )
1072
+ }
1073
+
1074
+ // Outside-click closer. Deferred via setTimeout so the opening click doesn't
1075
+ // immediately fire it; `armed` covers disarm racing ahead of the addEventListener.
1076
+ function armOutsideClose(keepOpenSelector: string, onOutside: () => void): () => void {
1077
+ let armed = true
1078
+ const handler = (e: MouseEvent) => {
1079
+ if ((e.target as Element | null)?.closest(keepOpenSelector)) return
1080
+ onOutside()
1081
+ }
1082
+ setTimeout(() => { if (armed) document.addEventListener('click', handler) }, 0)
1083
+ return () => { armed = false; document.removeEventListener('click', handler) }
1084
+ }
1085
+
1086
+ // ---- status-bar pill base classes ----
1087
+
1088
+ // A status-bar chip that toggles one upward popover. Centralises the popup lifecycle the three pills
1089
+ // share: single-popup coordination (closePopups dismisses the others as one opens) and the deferred
1090
+ // outside-click closer. Subclasses own the chip + popover view and set keepOpenSelector (the wrapper
1091
+ // the outside-click closer treats as "inside"); they reset per-open state in onClosed.
1092
+ abstract class StatusPill extends Component {
1093
+ open = false
1094
+ #disarm?: (() => void)
1095
+ protected abstract keepOpenSelector: string
1096
+
1097
+ get parent() { return this.ctx.parent as unknown as Root }
1098
+
1099
+ // Common open prologue: dismiss any sibling popup, then mark open. A subclass's show() calls this,
1100
+ // adds its own focus / data load, then calls armClose() once it's ready for the outside-click closer.
1101
+ protected beginOpen() {
1102
+ this.parent.closePopups(this)
1103
+ this.open = true
1104
+ }
1105
+
1106
+ // Arm (or re-arm) the deferred outside-click closer for this pill's wrapper.
1107
+ protected armClose() {
1108
+ this.#disarm?.()
1109
+ this.#disarm = armOutsideClose(this.keepOpenSelector, () => this.close())
1110
+ }
1111
+
1112
+ close() {
1113
+ this.#disarm?.(); this.#disarm = undefined
1114
+ if (!this.open) return
1115
+ this.open = false
1116
+ this.onClosed()
1117
+ this.update()
1118
+ }
1119
+
1120
+ // Reset per-open state on close (filter / highlight / drilled-in views). Default: nothing.
1121
+ protected onClosed() {}
1122
+ }
1123
+
1124
+ // A StatusPill whose popover is a filter box over a keyboard-navigable list (the session picker and
1125
+ // the bulb launcher). Owns the filter text, the highlight cursor, and the shared combobox key
1126
+ // handling; subclasses supply the list (itemCount/listEl), the activation action (onActivate), and
1127
+ // the filter input's id (filterId).
1128
+ abstract class ComboboxPill extends StatusPill {
1129
+ filter = ''
1130
+ highlighted = 0 // keyboard cursor into the filtered list (arrows move it, Enter activates)
1131
+
1132
+ protected abstract filterId: string
1133
+ protected abstract itemCount(): number
1134
+ protected abstract listEl(): Element | null
1135
+ protected abstract onActivate(index: number): void
1136
+
1137
+ protected override onClosed() {
1138
+ this.filter = ''
1139
+ this.highlighted = 0
1140
+ }
1141
+
1142
+ protected focusFilter() {
1143
+ setTimeout(() => document.getElementById(this.filterId)?.focus())
1144
+ }
1145
+
1146
+ clearFilter() {
1147
+ this.filter = ''
1148
+ this.highlighted = 0
1149
+ this.update()
1150
+ this.focusFilter()
1151
+ }
1152
+
1153
+ // Combobox cursor: move the highlight within the current list and scroll it into view (the one
1154
+ // leaky DOM touch — the VDOM owns the class, but scrollIntoView needs the real node).
1155
+ moveHighlight(delta: number) {
1156
+ const n = this.itemCount()
1157
+ if (!n) return
1158
+ this.highlighted = Math.max(0, Math.min(this.highlighted + delta, n - 1))
1159
+ this.update()
1160
+ setTimeout(() => (this.listEl()?.children[this.highlighted] as HTMLElement | undefined)?.scrollIntoView({ block: 'nearest' }))
1161
+ }
1162
+
1163
+ activateHighlighted() {
1164
+ const n = this.itemCount()
1165
+ if (n) this.onActivate(Math.max(0, Math.min(this.highlighted, n - 1)))
1166
+ }
1167
+
1168
+ // Filter-input keys: nav keys move/activate the highlight, Escape closes; any other key falls
1169
+ // through to edit the filter, restarting the highlight at the top.
1170
+ protected onFilterKey(e: KeyboardEvent) {
1171
+ if (e.key === 'Escape') this.close()
1172
+ else if (e.key === 'ArrowDown') { e.preventDefault(); this.moveHighlight(1) }
1173
+ else if (e.key === 'ArrowUp') { e.preventDefault(); this.moveHighlight(-1) }
1174
+ else if (e.key === 'Enter') { e.preventDefault(); this.activateHighlighted() }
1175
+ else this.highlighted = 0
1176
+ }
1177
+ }
1178
+
1179
+ // Sessions chip + dropdown. Owns the session list; reaches up to Root for sessionId/cwd and
1180
+ // updateTitle/stickToBottomNextRender. Opens on the attached session so the highlight starts where
1181
+ // you are; the `.active` row is the keyboard cursor.
1182
+ class SessionPicker extends ComboboxPill {
1183
+ sessions: { sessionId: string; mtime: number; preview: string }[] = []
1184
+ protected keepOpenSelector = '.sid-wrap'
1185
+ protected filterId = 'session-filter'
1186
+
1187
+ protected itemCount() { return this.filtered().length }
1188
+ protected listEl() { return document.getElementById('session-list') }
1189
+ protected onActivate(i: number) { const s = this.filtered()[i]; if (s) this.pick(s.sessionId) }
1190
+
1191
+ // The (possibly filtered) sessions the dropdown shows — matches preview text, case-insensitive.
1192
+ filtered() {
1193
+ const q = this.filter.trim().toLowerCase()
1194
+ return q ? this.sessions.filter(s => s.preview.toLowerCase().includes(q)) : this.sessions
1195
+ }
1196
+
1197
+ // Index of the currently-attached session in the filtered list (0 if it isn't listed yet).
1198
+ currentIndex(): number {
1199
+ const list = this.filtered()
1200
+ const i = list.findIndex(s => s.sessionId === this.parent.sessionId)
1201
+ return i < 0 ? 0 : i
1202
+ }
1203
+
1204
+ override onAttached() {
1205
+ // Silent failure: the picker stays empty until the user reopens it (retries).
1206
+ tb.server.listSessions().then(ss => {
1207
+ this.sessions = ss
1208
+ this.parent.updateTitle()
1209
+ this.update()
1210
+ }).catch(() => {})
1211
+ }
1212
+
1213
+ // Preview for the attached session (drives the tab title); empty until loaded.
1214
+ currentPreview(): string {
1215
+ return this.sessions.find(s => s.sessionId === this.parent.sessionId)?.preview?.trim() ?? ''
1216
+ }
1217
+
1218
+ async show() {
1219
+ this.beginOpen()
1220
+ this.highlighted = this.currentIndex()
1221
+ this.update()
1222
+ // Focus the filter input so it captures typing + arrow/Enter/Esc; domeleon patches
1223
+ // #session-filter in place across the reload below, so focus survives without re-focusing.
1224
+ this.focusFilter()
1225
+ try {
1226
+ this.sessions = await tb.server.listSessions()
1227
+ this.highlighted = this.currentIndex()
1228
+ this.parent.updateTitle()
1229
+ this.update()
1230
+ } catch (err) {
1231
+ console.error('[claude-bulb] listSessions failed', err)
1232
+ }
1233
+ // Armed after the load, not before: the list is in place, matching the prior behaviour.
1234
+ this.armClose()
1235
+ }
1236
+
1237
+ async pick(sessionId: string) {
1238
+ this.close()
1239
+ // Land at the bottom of the new session, not the old scroll position.
1240
+ this.parent.messageList.stickToBottomNextRender()
1241
+ await tb.server.attach(sessionId)
1242
+ }
1243
+
1244
+ view() {
1245
+ const p = this.parent
1246
+ if (!p.sessionId) return div({ class: 'sid-wrap' })
1247
+ const raw = this.currentPreview() || 'current'
1248
+ const label = truncate(raw, 25)
1249
+ const tip = `${p.cwd}\nSession: ${p.sessionId}` // cwd on hover, not in the bar
1250
+ return div({ class: 'sid-wrap' },
1251
+ button({
1252
+ class: 'pill',
1253
+ title: tip,
1254
+ onClick: (e: MouseEvent) => { e.stopPropagation(); this.open ? this.close() : this.show() },
1255
+ }, label),
1256
+ this.open ? this.picker() : null,
1257
+ )
1258
+ }
1259
+
1260
+ picker() {
1261
+ const sessions = this.filtered()
1262
+ return div({ class: 'picker' },
1263
+ // Reuses the launcher's filter chrome; the host owns the value + key handling.
1264
+ searchFilter({
1265
+ target: this,
1266
+ prop: () => this.filter,
1267
+ id: 'session-filter',
1268
+ placeholder: 'Filter sessions…',
1269
+ hasValue: !!this.filter,
1270
+ onKeyDown: (e: KeyboardEvent) => this.onFilterKey(e),
1271
+ onClear: () => this.clearFilter(),
1272
+ }),
1273
+ sessions.length === 0
1274
+ ? div({ class: 'picker-empty' }, this.filter ? 'No match.' : 'No sessions yet — start one in your terminal.')
1275
+ : div({ id: 'session-list', class: 'picker-list' }, sessions.map((s, i) => this.pickerRow(s, i))),
1276
+ )
1277
+ }
1278
+
1279
+ pickerRow(s: { sessionId: string; mtime: number; preview: string }, i: number) {
1280
+ const current = s.sessionId === this.parent.sessionId
1281
+ return div({
1282
+ // `.active` is the keyboard cursor; `.current` marks the attached session so it stays
1283
+ // identifiable when the cursor moves off it.
1284
+ class: ['picker-row', i === this.highlighted ? 'active' : '', current ? 'current' : ''],
1285
+ onMouseEnter: () => { if (this.highlighted !== i) { this.highlighted = i; this.update() } },
1286
+ onClick: () => this.pick(s.sessionId),
1287
+ },
1288
+ span({ class: 'picker-dot' }),
1289
+ span({ class: 'picker-preview' }, s.preview || '(no preview)'),
1290
+ span({ class: 'picker-time' }, relTime(s.mtime)),
1291
+ )
1292
+ }
1293
+ }
1294
+
1295
+ // Token counter chip + click popup. Reaches up to Root for `tokens` (already
1296
+ // accumulated from the JSONL `usage` events — no extra server call). The popup
1297
+ // breaks the count down into cached / new input and output. No cost — public
1298
+ // pricing is notional on a subscription.
1299
+ class TokenPill extends StatusPill {
1300
+ protected keepOpenSelector = '.token-wrap'
1301
+
1302
+ show() {
1303
+ this.beginOpen()
1304
+ this.update()
1305
+ this.armClose()
1306
+ }
1307
+
1308
+ view() {
1309
+ const t = this.parent.tokens
1310
+ // Headline = output only — the one number that's purely "what the model
1311
+ // produced." Input detail (incl. cache split) lives in the popup.
1312
+ if (t.in + t.out + t.cached + t.cacheCreate <= 0) return div({ class: 'token-wrap' })
1313
+ return div({ class: 'token-wrap' },
1314
+ button({
1315
+ class: 'pill',
1316
+ onClick: (e: MouseEvent) => { e.stopPropagation(); this.open ? this.close() : this.show() },
1317
+ }, `${formatTokens(t.out)} tokens`),
1318
+ this.open ? this.popup(t) : null,
1319
+ )
1320
+ }
1321
+
1322
+ popup(t: { in: number; out: number; cached: number; cacheCreate: number }) {
1323
+ // Three flat numbers, each a token count with a plain label. No total, no
1324
+ // grouping — input/output (and cached/uncached input) are priced differently,
1325
+ // so they stay separate rather than summed. Cached vs uncached input is the
1326
+ // cache-health signal.
1327
+ return div({ class: 'token-pop' },
1328
+ this.line('Cache', t.cached, 'input tokens reused from cache'),
1329
+ this.line('Input', t.in + t.cacheCreate, 'new input tokens sent to model'),
1330
+ this.line('Output', t.out, 'tokens output by model'),
1331
+ )
1332
+ }
1333
+ line(title: string, n: number, label: string) {
1334
+ return div({ class: 'token-line' },
1335
+ div({ class: 'token-line-left' },
1336
+ span({ class: 'token-line-title' }, title),
1337
+ span({ class: 'token-line-lbl' }, label),
1338
+ ),
1339
+ span({ class: 'token-line-num' }, formatTokens(n)),
1340
+ )
1341
+ }
1342
+ }
1343
+
1344
+ interface RunningServer { pid: number; port: number; url: string; file: string; startedAt: number; trust?: boolean; predicted?: string; denied?: string }
1345
+ interface BulbFile { path: string; name: string; mtime: number; trusted?: boolean }
1346
+ // One launcher row: a project bulb and/or a running server, merged by path. `running`
1347
+ // present ⇒ live (open link + stop); absent ⇒ stopped (launch). `recent` = MRU sort key.
1348
+ // `trusted` = a remembered trust decision (applies to the next launch).
1349
+ interface BulbRow { path: string; name: string; recent: number; trusted?: boolean; running?: RunningServer }
1350
+
1351
+ // Inline SVG play/stop icons for the launch/stop controls (the pattern from
1352
+ // typebulbs/xor-x-ray.bulb.md): font glyphs centre unpredictably across platforms, a fixed
1353
+ // viewBox is reliable, and currentColor lets the button's own colour flow through.
1354
+ const btnIcon = (...shapes: unknown[]) => svg({ viewBox: '0 0 16 16', width: '13', height: '13', class: 'btn-icon' }, ...shapes as never[])
1355
+ // Triangle nudged ~2px right of centre: a right-pointing triangle's visual mass sits left of its
1356
+ // bounding box, so geometric centring reads as too-far-left in the round button.
1357
+ const iconPlay = () => btnIcon(path({ d: 'M5.5 2 L15.5 8 L5.5 14 Z', fill: 'currentColor' }))
1358
+ const iconStop = () => btnIcon(rect({ x: 3, y: 3, width: 10, height: 10, fill: 'currentColor' }))
1359
+
1360
+ // Match a globbed file path (join) against a registry path (resolve) across the abs/case
1361
+ // gap: lowercased, forward-slashed. Windows paths are case-insensitive; the row keeps the
1362
+ // original path for launch + display, this is only the key.
1363
+ function pathKey(p: string): string { return p.replace(/\\/g, '/').toLowerCase() }
1364
+
1365
+ // Last path segment, trailing separators trimmed — the file or directory name ('' for an empty path).
1366
+ function basename(p: string): string {
1367
+ return p.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? ''
1368
+ }
1369
+ // A bulb's display name from its path: the basename minus the `.bulb.md` suffix.
1370
+ function bulbBasename(p: string): string {
1371
+ return basename(p).replace(/\.bulb\.md$/, '')
1372
+ }
1373
+
1374
+ // Status-bar bulb launcher + off-switch. Lists every *.bulb.md in the project (MRU-first,
1375
+ // type to filter) so a bulb you just authored is one click from running — no trip to the
1376
+ // terminal — and overlays the machine-global running-server registry so the same menu
1377
+ // stops them. A broken-out/launched server is detached and outlives this page (surviving
1378
+ // every hot-reload), so without this the only way to stop one is the OS. The bulb drives
1379
+ // nothing except launching (Invariant 2) — the same deliberate exception as breakout.
1380
+ //
1381
+ // The host's own file (the mirror you're looking through) is dropped — no relaunching
1382
+ // yourself — identified by *pid*, not filename, so it survives a rename and doesn't hide a
1383
+ // second, unrelated mirror. Polls on a lazy cadence: the set changes on a launch / stop /
1384
+ // breakout / edit, not per-frame.
1385
+ class BulbsPill extends ComboboxPill {
1386
+ files: BulbFile[] = []
1387
+ servers: RunningServer[] = []
1388
+ protected keepOpenSelector = '.servers-wrap'
1389
+ protected filterId = 'bulb-filter'
1390
+ // Bulb paths (pathKey) mid-launch — a transient in-memory state (NO persistence): launchBulbServer
1391
+ // doesn't resolve until the server registers (~2s), so the play button shimmers until then.
1392
+ launching = new Set<string>()
1393
+ // Denials (by pid) the user chose to keep restricted, so the elevation modal doesn't
1394
+ // nag again for the same running bulb. Resets when that bulb stops (new pid).
1395
+ dismissedDenials = new Set<number>()
1396
+ // A stopped bulb the proactive scan thinks needs trust, awaiting the user's launch-tier choice.
1397
+ // Set by launch() before anything spawns, so the offer precedes the tab opening (not after the
1398
+ // server registers). Cleared on choice or dismiss.
1399
+ pendingLaunch?: { path: string; name: string; cap: string }
1400
+ // The bulb whose console is being tailed, if any (one at a time).
1401
+ openLog?: { pid: number; name: string; text: string; offset: number }
1402
+ #logTimer?: ReturnType<typeof setInterval>
1403
+
1404
+ protected itemCount() { return this.rows().length }
1405
+ protected listEl() { return document.querySelector('.bulb-list') }
1406
+ // Enter on the highlighted row: open a running server's tab, or launch a stopped bulb.
1407
+ protected onActivate(i: number) {
1408
+ const r = this.rows()[i]
1409
+ if (r) r.running ? window.open(r.running.url, '_blank', 'noopener') : this.launch(r.path)
1410
+ }
1411
+
1412
+ override onAttached() {
1413
+ this.refresh()
1414
+ // The running registry is cheap (reads one dir) and carries the time-critical signal —
1415
+ // a denial flag that must raise the elevation modal promptly — so poll it fast. The file
1416
+ // walk is heavier (recursive project scan) and only changes on author/launch, so it stays
1417
+ // lazy. Splitting them keeps the modal near-instant without re-walking the tree every tick.
1418
+ setInterval(() => this.refreshServers(), 800)
1419
+ setInterval(() => this.refreshFiles(), 3000)
1420
+ // breakout resolves only after the new server has self-registered, so this nudge
1421
+ // refreshes immediately rather than waiting for the next backstop tick.
1422
+ window.addEventListener('tb-breakout', () => this.refreshServers())
1423
+ }
1424
+
1425
+ refresh() { return Promise.all([this.refreshServers(), this.refreshFiles()]) }
1426
+
1427
+ // Running registry only. Re-render on any change that the UI shows — count, identity, AND
1428
+ // the per-server flags the rows/modal key off (`denied`/`predicted` drive the elevation modal;
1429
+ // `trust` drives the badge). Omitting the flags is why the modal used to lag seconds behind a
1430
+ // denial: it changes neither count nor pid, so the old check stayed false and skipped update().
1431
+ async refreshServers() {
1432
+ try {
1433
+ const servers = await tb.server.listBreakouts() as RunningServer[]
1434
+ const changed =
1435
+ servers.length !== this.servers.length ||
1436
+ servers.some((s, i) =>
1437
+ s.pid !== this.servers[i]?.pid ||
1438
+ s.denied !== this.servers[i]?.denied ||
1439
+ s.predicted !== this.servers[i]?.predicted ||
1440
+ s.trust !== this.servers[i]?.trust)
1441
+ this.servers = servers
1442
+ if (changed) this.update()
1443
+ } catch (err) {
1444
+ console.error('[claude-bulb] server list refresh failed', err)
1445
+ }
1446
+ }
1447
+
1448
+ // Project *.bulb.md files (the heavier recursive walk). Re-render only on a real change so
1449
+ // the lazy tick is otherwise invisible.
1450
+ async refreshFiles() {
1451
+ try {
1452
+ const files = await tb.server.listBulbFiles() as BulbFile[]
1453
+ const changed =
1454
+ files.length !== this.files.length ||
1455
+ files.some((f, i) => f.path !== this.files[i]?.path || f.mtime !== this.files[i]?.mtime)
1456
+ this.files = files
1457
+ if (changed) this.update()
1458
+ } catch (err) {
1459
+ console.error('[claude-bulb] bulb list refresh failed', err)
1460
+ }
1461
+ }
1462
+
1463
+ // Project files ∪ running servers, keyed by path. The host's own file (and byte-identical copies)
1464
+ // are already dropped server-side in listBulbFiles; its own running server is dropped here by pid.
1465
+ // A running server outside the project still shows so the off-switch stays unified. MRU = max(file
1466
+ // mtime, server startedAt), so just-edited and just-launched both float up. Filter matches name + path.
1467
+ rows(): BulbRow[] {
1468
+ const byKey = new Map<string, BulbRow>()
1469
+ for (const f of this.files) {
1470
+ byKey.set(pathKey(f.path), { path: f.path, name: f.name, recent: f.mtime, trusted: f.trusted })
1471
+ }
1472
+ for (const s of this.servers) {
1473
+ if (s.pid === this.parent.ownPid) continue
1474
+ const k = pathKey(s.file)
1475
+ const base = bulbBasename(s.file) || s.file
1476
+ const row = byKey.get(k) ?? { path: s.file, name: base, recent: 0 }
1477
+ row.running = s
1478
+ row.recent = Math.max(row.recent, s.startedAt)
1479
+ byKey.set(k, row)
1480
+ }
1481
+ const q = this.filter.trim().toLowerCase()
1482
+ let rows = [...byKey.values()]
1483
+ if (q) rows = rows.filter(r => r.name.toLowerCase().includes(q) || r.path.toLowerCase().includes(q))
1484
+ return rows.sort((a, b) => b.recent - a.recent)
1485
+ }
1486
+
1487
+ show() {
1488
+ this.beginOpen()
1489
+ this.highlighted = 0
1490
+ this.refresh()
1491
+ this.update()
1492
+ this.armClose()
1493
+ this.focusFilter()
1494
+ }
1495
+
1496
+ protected override onClosed() {
1497
+ super.onClosed()
1498
+ this.closeLog()
1499
+ }
1500
+
1501
+ // Launch a stopped bulb — but PROBE TRUST FIRST so the offer precedes the tab. A
1502
+ // remembered-trusted bulb (its tier already decided) and a bulb the scan reads as benign both
1503
+ // launch straight away; only a fresh bulb the scan flags raises the pre-launch modal, and the
1504
+ // spawn waits for the user's tier choice. (A scan miss still falls through to the runtime gate's
1505
+ // reactive modal — see pendingDenial.)
1506
+ async launch(path: string) {
1507
+ const trusted = this.files.find(f => pathKey(f.path) === pathKey(path))?.trusted
1508
+ if (!trusted) {
1509
+ let cap: string | undefined
1510
+ try { cap = (await tb.server.predictTrustOf(path) as { cap?: string }).cap }
1511
+ catch (err) { console.error('[claude-bulb] predictTrustOf failed', err) }
1512
+ if (cap) { this.pendingLaunch = { path, name: this.displayName(path), cap }; this.update(); return }
1513
+ }
1514
+ await this.doLaunch(path)
1515
+ }
1516
+
1517
+ // The actual spawn. `trust` true → launch trusted + remember it (the pre-launch "Launch Trusted"
1518
+ // choice, parallel to elevate); omitted → the server resolves the remembered tier itself.
1519
+ // `suppressNag` records the new pid as already-dismissed so a deliberate Restricted launch isn't
1520
+ // re-prompted the instant it trips the gate (elevate later via the row's trust toggle).
1521
+ async doLaunch(path: string, trust?: boolean, suppressNag = false) {
1522
+ // Mark launching so the play button stays visible + shimmers for the ~2s until the server
1523
+ // registers (else the click feels dead). Transient, in-memory; cleared when refresh flips the
1524
+ // row to running (which renders a stop button instead).
1525
+ const key = pathKey(path)
1526
+ this.launching.add(key); this.update()
1527
+ let pid: number | undefined
1528
+ try { pid = (await tb.server.launchBulb(path, trust) as { pid?: number }).pid }
1529
+ catch (err) { console.error('[claude-bulb] launchBulb failed', err) }
1530
+ finally { this.launching.delete(key) }
1531
+ if (suppressNag && pid) this.dismissedDenials.add(pid)
1532
+ await this.refresh()
1533
+ }
1534
+
1535
+ // Bulb's frontmatter name when known (nicer than the filename), else the basename sans extension.
1536
+ displayName(path: string): string {
1537
+ const f = this.files.find(x => pathKey(x.path) === pathKey(path))
1538
+ return f?.name || bulbBasename(path) || path
1539
+ }
1540
+
1541
+ // Pre-launch modal actions.
1542
+ launchTrusted() { const p = this.pendingLaunch; this.pendingLaunch = undefined; if (p) this.doLaunch(p.path, true) }
1543
+ launchRestricted() { const p = this.pendingLaunch; this.pendingLaunch = undefined; if (p) this.doLaunch(p.path, undefined, true) }
1544
+ cancelLaunch() { this.pendingLaunch = undefined; this.update() }
1545
+
1546
+ async stop(pid: number) {
1547
+ if (this.openLog?.pid === pid) this.closeLog()
1548
+ this.dismissedDenials.delete(pid)
1549
+ try { await tb.server.stopBreakout(pid) }
1550
+ catch (err) { console.error('[claude-bulb] stopBreakout failed', err) }
1551
+ await this.refresh()
1552
+ }
1553
+
1554
+ // A running untrusted bulb that actually tripped the gate (`denied`), awaiting an elevation
1555
+ // decision. This is the REACTIVE path — the proactive offer happens before launch (see launch()),
1556
+ // so here `denied` is either a deliberate-Restricted bulb the user let trip, or a scan MISS (the
1557
+ // bulb reached a capability its source didn't visibly use — the suspicious case, flagged in the
1558
+ // modal via `denied` without `predicted`). Surfaced even when the popover is closed; skips ones
1559
+ // the user already dismissed (by pid).
1560
+ pendingDenial(): RunningServer | undefined {
1561
+ return this.servers.find(s => s.denied && s.pid !== this.parent.ownPid && !this.dismissedDenials.has(s.pid))
1562
+ }
1563
+
1564
+ // Elevate: stop the untrusted server, relaunch it trusted (explicit trust → the host
1565
+ // remembers it). Trust is fixed at process start, so this is a restart, not an in-place flip.
1566
+ async elevate(s: RunningServer) {
1567
+ this.dismissedDenials.add(s.pid)
1568
+ if (this.openLog?.pid === s.pid) this.closeLog()
1569
+ try {
1570
+ await tb.server.stopBreakout(s.pid)
1571
+ await tb.server.launchBulb(s.file, true)
1572
+ } catch (err) { console.error('[claude-bulb] elevate failed', err) }
1573
+ await this.refresh()
1574
+ }
1575
+
1576
+ dismissDenial(pid: number) { this.dismissedDenials.add(pid); this.update() }
1577
+
1578
+ // Flip a bulb's trust tier. Always updates the remembered decision; for a running server the
1579
+ // tier is fixed at process start, so apply it now by stopping and relaunching in the new tier
1580
+ // (so the toggle reflects reality rather than only the next launch).
1581
+ async toggleTrust(r: BulbRow) {
1582
+ const next = !(r.running ? !!r.running.trust : !!r.trusted)
1583
+ try {
1584
+ await tb.server.setBulbTrust(r.path, next)
1585
+ if (r.running) {
1586
+ if (this.openLog?.pid === r.running.pid) this.closeLog()
1587
+ await tb.server.stopBreakout(r.running.pid)
1588
+ await tb.server.launchBulb(r.path, next)
1589
+ }
1590
+ } catch (err) { console.error('[claude-bulb] toggleTrust failed', err) }
1591
+ await this.refresh()
1592
+ }
1593
+
1594
+ // Tail a running server's console: poll readBulbLog from a byte offset and append, the
1595
+ // same drain pattern as the transcript. Display is capped to recent output so a chatty
1596
+ // server doesn't bloat the DOM. One log open at a time.
1597
+ showLog(s: RunningServer, name: string) {
1598
+ if (this.openLog?.pid === s.pid) { this.closeLog(); return }
1599
+ this.closeLog()
1600
+ this.openLog = { pid: s.pid, name, text: '', offset: 0 }
1601
+ this.update()
1602
+ setTimeout(() => document.getElementById('bulb-console')?.focus()) // so Esc returns to the list
1603
+ this.#pollLog()
1604
+ this.#logTimer = setInterval(() => this.#pollLog(), 700)
1605
+ }
1606
+ closeLog() {
1607
+ if (this.#logTimer) { clearInterval(this.#logTimer); this.#logTimer = undefined }
1608
+ if (!this.openLog) return
1609
+ this.openLog = undefined
1610
+ this.update()
1611
+ }
1612
+ async #pollLog() {
1613
+ const log = this.openLog
1614
+ if (!log) return
1615
+ try {
1616
+ const { text, offset } = await tb.server.readBulbLog(log.pid, log.offset) as { text: string; offset: number }
1617
+ if (this.openLog !== log) return // closed/switched mid-await
1618
+ if (text) {
1619
+ log.text = (log.text + text).slice(-50_000) // keep the recent tail
1620
+ log.offset = offset
1621
+ this.update()
1622
+ } else if (offset !== log.offset) {
1623
+ log.offset = offset // file trimmed/rotated — resync silently
1624
+ }
1625
+ } catch (err) {
1626
+ console.error('[claude-bulb] readBulbLog failed', err)
1627
+ }
1628
+ }
1629
+
1630
+ view() {
1631
+ const running = this.servers.filter(s => s.pid !== this.parent.ownPid).length
1632
+ const launchable = this.files.length > 0 // self + byte-identical copies already dropped in listBulbFiles
1633
+ if (!launchable && running === 0) return div({ class: 'servers-wrap' }) // nothing to do → no chip
1634
+ const label = running > 0 ? `${running} running` : 'bulbs'
1635
+ const denial = this.pendingDenial()
1636
+ return div({ class: 'servers-wrap' },
1637
+ button({
1638
+ class: 'pill',
1639
+ title: 'Launch a project bulb · stop a running one',
1640
+ onClick: (e: MouseEvent) => { e.stopPropagation(); this.open ? this.close() : this.show() },
1641
+ }, label),
1642
+ this.open ? this.popup() : null,
1643
+ // Pre-launch offer (proactive) wins the slot if present — it's the one blocking a launch;
1644
+ // the reactive denial modal handles an already-running bulb that tripped the gate.
1645
+ this.pendingLaunch ? this.launchModal(this.pendingLaunch) : denial ? this.denialModal(denial) : null,
1646
+ )
1647
+ }
1648
+
1649
+ // Shared modal chrome for both trust prompts (Specs/Typebulb-CLI-Trust.md). It lives here, in the
1650
+ // launcher — a surface the launched bulb's page can't script — so a bulb can trigger a prompt but
1651
+ // never self-grant (Trust spec Invariant 3). Backdrop click runs onDismiss.
1652
+ trustModal(cfg: { heading: string; body: string; warn: string; noLabel: string; yesLabel: string; onNo: () => void; onYes: () => void; onDismiss: () => void }) {
1653
+ return div({ class: 'trust-back', onClick: (e: MouseEvent) => { e.stopPropagation(); cfg.onDismiss() } },
1654
+ div({ class: 'trust-modal', onClick: (e: MouseEvent) => e.stopPropagation() },
1655
+ div({ class: 'trust-modal-h' }, cfg.heading),
1656
+ div({ class: 'trust-modal-b' }, cfg.body),
1657
+ div({ class: 'trust-modal-warn' }, cfg.warn),
1658
+ div({ class: 'trust-modal-acts' },
1659
+ button({ class: 'trust-no', onClick: (e: MouseEvent) => { e.stopPropagation(); cfg.onNo() } }, cfg.noLabel),
1660
+ button({ class: 'trust-yes', onClick: (e: MouseEvent) => { e.stopPropagation(); cfg.onYes() } }, cfg.yesLabel),
1661
+ ),
1662
+ ),
1663
+ )
1664
+ }
1665
+
1666
+ // Proactive (pre-launch): the scan thinks this bulb needs a capability; choose its tier before it
1667
+ // opens. "Launch Restricted" still launches — the user may want the sandboxed run — just without
1668
+ // trust, and without an instant re-nag when it trips (doLaunch suppressNag).
1669
+ launchModal(p: { path: string; name: string; cap: string }) {
1670
+ return this.trustModal({
1671
+ heading: `“${p.name}” will likely need access`,
1672
+ body: `It looks like it uses ${p.cap}, which is blocked unless you launch it Trusted. Launch Trusted to allow filesystem / AI / server.ts?`,
1673
+ warn: 'Only trust bulbs you wrote or have read — be careful with anything off the internet.',
1674
+ noLabel: 'Launch restricted',
1675
+ yesLabel: 'Launch trusted',
1676
+ onNo: () => this.launchRestricted(),
1677
+ onYes: () => this.launchTrusted(),
1678
+ onDismiss: () => this.cancelLaunch(),
1679
+ })
1680
+ }
1681
+
1682
+ // Reactive (post-launch): a running bulb tripped the gate. With the proactive offer now made
1683
+ // before launch, this fires for a deliberate-Restricted bulb the user let trip, or — the
1684
+ // interesting case — a scan MISS: a bulb that reached a capability its source never visibly used
1685
+ // (`denied` without `predicted`), which is exactly what dynamic access / a raw fetch('/__fs')
1686
+ // looks like, so it's headlined and warned harder. "Trust & relaunch" remembers the decision.
1687
+ denialModal(s: RunningServer) {
1688
+ const name = bulbBasename(s.file) || s.file
1689
+ const suspicious = !s.predicted
1690
+ return this.trustModal({
1691
+ heading: suspicious ? `“${name}” reached for access it didn't declare` : `“${name}” wants more access`,
1692
+ body: suspicious
1693
+ ? `It tried to use ${s.denied} at runtime, though nothing in its code obviously needed it. Relaunch it Trusted to allow filesystem / AI / server.ts?`
1694
+ : `It tried to use ${s.denied}, blocked while it's restricted. Relaunch it Trusted to allow filesystem / AI / server.ts?`,
1695
+ warn: suspicious
1696
+ ? 'Code that reaches for capabilities it never visibly uses can be hiding something — only continue if you wrote this bulb or have read it.'
1697
+ : 'Only trust bulbs you wrote or have read — be careful with anything off the internet.',
1698
+ noLabel: 'Keep restricted',
1699
+ yesLabel: 'Trust & relaunch',
1700
+ onNo: () => this.dismissDenial(s.pid),
1701
+ onYes: () => this.elevate(s),
1702
+ onDismiss: () => this.dismissDenial(s.pid),
1703
+ })
1704
+ }
1705
+
1706
+ popup() {
1707
+ // Drill-in: a running row's `logs` button flips the whole popover to that server's
1708
+ // console; ← back flips home. The launcher list and a streaming console are different
1709
+ // surfaces — one narrow and transient, one tall and kept open while you poke the bulb —
1710
+ // so they take turns in the popover rather than the console hiding below a scrolling list.
1711
+ if (this.openLog) return this.consoleView(this.openLog)
1712
+ const rows = this.rows()
1713
+ return div({ class: 'servers-pop' },
1714
+ // Nav keys move the highlight; any other key edits the filter, which refilters the list — so
1715
+ // restart the highlight at the top (applied on the input event's re-render, no extra update).
1716
+ searchFilter({
1717
+ target: this,
1718
+ prop: () => this.filter,
1719
+ id: 'bulb-filter',
1720
+ placeholder: 'Filter bulbs…',
1721
+ hasValue: !!this.filter,
1722
+ onKeyDown: (e: KeyboardEvent) => this.onFilterKey(e),
1723
+ onClear: () => this.clearFilter(),
1724
+ }),
1725
+ rows.length === 0
1726
+ ? div({ class: 'picker-empty' }, this.filter ? 'No match.' : 'No bulbs in this project yet.')
1727
+ : div({ class: 'bulb-list' }, rows.map((r, i) => this.row(r, i))),
1728
+ )
1729
+ }
1730
+
1731
+ // The drilled-in console for one running server: takes over the whole popover (wider +
1732
+ // taller than the list — a console wants room) with a back affordance to the list.
1733
+ // Esc or the ← button returns; closeLog stops the tail.
1734
+ consoleView(log: { name: string; text: string }) {
1735
+ return div({ id: 'bulb-console', class: 'servers-pop log-mode', tabIndex: 0,
1736
+ onKeyDown: (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); this.closeLog() } } },
1737
+ // Header content is right-aligned, back button rightmost.
1738
+ div({ class: 'bulb-log-head' },
1739
+ span({ class: 'bulb-log-name' }, `console · ${log.name}`),
1740
+ button({ class: 'bulb-log-back', title: 'Back to the bulb list', onClick: (e: MouseEvent) => { e.stopPropagation(); this.closeLog() } }, '← bulbs'),
1741
+ ),
1742
+ // Top-anchored: output reads from the first line down (a short log sits at the top,
1743
+ // not floated to the bottom of the tall box).
1744
+ pre({ class: 'bulb-log-body' }, span({ class: 'bulb-log-text' }, log.text || '(no output yet)')),
1745
+ )
1746
+ }
1747
+
1748
+ row(r: BulbRow, i: number) {
1749
+ const s = r.running
1750
+ const showing = s && this.openLog?.pid === s.pid
1751
+ // Running tier is authoritative; otherwise the remembered decision the next launch uses.
1752
+ const trusted = s ? !!s.trust : !!r.trusted
1753
+ // Layout: the action button + name read together on the LEFT (▶/■ Title); the metadata/controls
1754
+ // form a right-aligned cluster of fixed-width columns (trust · logs · time/port) that line up
1755
+ // row-to-row. Right-anchored, so the rightmost column (time/port) always aligns; the others
1756
+ // stack inward from it.
1757
+ return div({ class: ['server-row', i === this.highlighted ? 'active' : ''],
1758
+ onMouseEnter: () => { if (this.highlighted !== i) { this.highlighted = i; this.update() } } },
1759
+ s
1760
+ ? button({ class: 'server-stop', title: 'Stop this server', ariaLabel: 'Stop', onClick: (e: MouseEvent) => { e.stopPropagation(); this.stop(s.pid) } }, iconStop())
1761
+ : button({ class: ['bulb-launch', this.launching.has(pathKey(r.path)) ? 'launching' : ''], title: trusted ? 'Launch (trusted — remembered)' : 'Launch (restricted)', ariaLabel: 'Launch', onClick: (e: MouseEvent) => { e.stopPropagation(); this.launch(r.path) } }, iconPlay()),
1762
+ // The name opens the bulb's .bulb.md in the editor (running or stopped). The live app is
1763
+ // reachable separately via the :port link; the play button launches. Not a launch trigger.
1764
+ span({ class: ['server-name', s ? '' : 'stopped'], title: `Open ${r.path}`, onClick: (e: MouseEvent) => { e.stopPropagation(); tb.server.openFile(r.path) } }, r.name),
1765
+ // Trust toggle. Shown for a running server (the live tier matters) or a trusted-remembered
1766
+ // stopped bulb; a plain restricted stopped bulb shows an empty cell — "restricted" is the
1767
+ // implicit default and repeating it down every row is noise — but the cell still holds its grid
1768
+ // column, so the trust/logs/time columns stay aligned row-to-row.
1769
+ (s || trusted) ? this.trustToggle(r, trusted) : span({ class: 'cell-empty' }),
1770
+ // Logs: a link (not a button) that flips the popover to this server's console.
1771
+ s
1772
+ ? a({ class: ['server-logs', showing ? 'on' : ''], title: 'Show this server’s console', onClick: (e: MouseEvent) => { e.stopPropagation(); this.showLog(s, r.name) } }, 'logs')
1773
+ : span({ class: 'cell-empty' }),
1774
+ s
1775
+ ? a({ class: 'server-port', href: s.url, target: '_blank', rel: 'noopener noreferrer', title: `Open ${s.url}` }, `:${s.port}`)
1776
+ : span({ class: 'bulb-time' }, relTime(r.recent)),
1777
+ )
1778
+ }
1779
+
1780
+ // Trust toggle: one button showing the *current* tier (one word; clicking flips it). Shares the
1781
+ // row-button style — accent when trusted (an active control), muted when restricted. For a running
1782
+ // server the flip is a stop + relaunch (toggleTrust).
1783
+ trustToggle(r: BulbRow, trusted: boolean) {
1784
+ return button({
1785
+ class: ['trust-toggle', trusted ? 'on' : ''],
1786
+ title: `${trusted ? 'Trusted' : 'Restricted'} — click to switch to ${trusted ? 'restricted' : 'trusted'}` + (r.running ? ' (stops + relaunches it)' : ''),
1787
+ onClick: (e: MouseEvent) => { e.stopPropagation(); this.toggleTrust(r) },
1788
+ }, trusted ? 'trusted' : 'restricted')
1789
+ }
1790
+ }
1791
+
1792
+ // A live ````bulb```` embed: a sandboxed nested app plus its controls. createBulbFrame (the
1793
+ // typebulb package) compiles the source into an auto-sizing iframe and owns sandbox policy,
1794
+ // host-theme inheritance, and runtime-error forwarding; we own placement and the inline⇄spread /
1795
+ // code⇄run / copy / breakout controls. The iframe is parented once via onMounted — moving an
1796
+ // <iframe> in the DOM reloads it — and the source listing is markdown rendered through innerHTML.
1797
+ class BulbEmbed extends Component {
1798
+ spread = false
1799
+ showingCode = false
1800
+ copied = false
1801
+ #breakoutResult = '' // 'launched <file>' / 'breakout failed', shown while state === 'done'
1802
+ #source: string
1803
+ #key: string
1804
+ #frame?: HTMLElement
1805
+ #compileError?: string
1806
+ #runtimeError?: string
1807
+ #breakoutState: 'idle' | 'busy' | 'done' = 'idle' // busy: shimmer+disabled; done: result shown, still disabled
1808
+
1809
+ constructor(source: string, key: string) {
1810
+ super()
1811
+ this.#source = source
1812
+ this.#key = key
1813
+ }
1814
+
1815
+ // Compile once, when first attached to the tree. A post-mount runtime throw arrives via onError
1816
+ // and surfaces as a strip under the still-running embed; a compile failure rejects to the fallback.
1817
+ override onAttached() {
1818
+ createBulbFrame(this.#source, { onError: (m: string) => { this.#runtimeError = m; this.update() } })
1819
+ .then(frame => { this.#frame = frame; this.update() })
1820
+ .catch((err: unknown) => { this.#compileError = err instanceof Error ? err.message : String(err); this.update() })
1821
+ }
1822
+
1823
+ view() {
1824
+ const cls = ['bulb-embed', this.spread ? 'spread' : 'inline', this.showingCode ? 'code-open' : '', this.#compileError ? 'err' : '']
1825
+ const inner =
1826
+ this.#compileError ? [`bulb: ${this.#compileError}`]
1827
+ : !this.#frame ? ['compiling bulb…']
1828
+ : [
1829
+ this.#controls(),
1830
+ // Frame host: display:contents (see CSS) so the iframe lays out as a direct child of
1831
+ // .bulb-embed — the sizing / spread-breakout rules target it — while domeleon owns this
1832
+ // node. onMounted parents the iframe exactly once; the code-open class hides it.
1833
+ div({ class: 'bulb-frame', key: 'frame', onMounted: (el: Element) => { if (this.#frame) el.replaceChildren(this.#frame) } }),
1834
+ this.showingCode ? this.#codeView() : null,
1835
+ this.#runtimeError ? div({ class: 'bulb-err-strip', key: 'err' }, `⚠ ${this.#runtimeError}`) : null,
1836
+ ]
1837
+ // Wrapped in `.md` so the embed's CSS (`.md .bulb-embed …`) applies as a bubble sibling.
1838
+ return div({ class: 'md', key: this.#key }, div({ class: cls }, ...inner))
1839
+ }
1840
+
1841
+ #controls() {
1842
+ const breakoutLabel = this.#breakoutState === 'done' ? this.#breakoutResult : 'breakout ↗'
1843
+ return div({ class: 'bulb-controls', key: 'controls' },
1844
+ this.#pill('bulb-view', this.spread ? 'inline' : 'spread',
1845
+ this.spread ? 'Fit into the conversation column' : 'Spread to the full transcript width and height',
1846
+ () => { this.spread = !this.spread; this.update() }),
1847
+ this.#pill('bulb-toggle', this.showingCode ? 'run' : 'code',
1848
+ this.showingCode ? 'Back to the running bulb' : "View this bulb's source",
1849
+ () => { this.showingCode = !this.showingCode; this.update() }),
1850
+ this.#pill(['bulb-copy', this.copied ? 'done' : ''], this.copied ? 'copied' : 'copy',
1851
+ "Copy this bulb's full source", () => this.#copy()),
1852
+ button({ class: ['overlay-pill', 'bulb-breakout', this.#breakoutState === 'busy' ? 'launching' : ''], type: 'button',
1853
+ disabled: this.#breakoutState !== 'idle', title: 'Save as a standalone .bulb.md and open it with typebulb',
1854
+ onClick: (e: MouseEvent) => { e.stopPropagation(); this.#breakout() } }, breakoutLabel),
1855
+ )
1856
+ }
1857
+
1858
+ #pill(extra: string | string[], label: string, title: string, onClick: () => void) {
1859
+ const extras = Array.isArray(extra) ? extra : [extra]
1860
+ return button({ class: ['overlay-pill', ...extras], type: 'button', title,
1861
+ onClick: (e: MouseEvent) => { e.stopPropagation(); onClick() } }, label)
1862
+ }
1863
+
1864
+ #copy() {
1865
+ navigator.clipboard?.writeText(this.#source)
1866
+ this.copied = true
1867
+ this.update()
1868
+ setTimeout(() => { this.copied = false; this.update() }, 1200)
1869
+ }
1870
+
1871
+ // Write this bulb to a standalone <name>.bulb.md and launch it — the graduation path from an
1872
+ // inline experiment to a real bulb. Shimmers through the ~2s write+spawn, stays disabled through
1873
+ // the result window so a stray double-click can't launch twice.
1874
+ async #breakout() {
1875
+ if (this.#breakoutState !== 'idle') return
1876
+ this.#breakoutState = 'busy'
1877
+ this.update()
1878
+ try {
1879
+ const { file } = await tb.server.breakout(this.#source)
1880
+ this.#breakoutResult = `launched ${file}`
1881
+ // breakout resolves only once the new server has registered — nudge the status-bar pill now.
1882
+ window.dispatchEvent(new CustomEvent('tb-breakout'))
1883
+ } catch (err) {
1884
+ this.#breakoutResult = 'breakout failed'
1885
+ console.error('[claude-bulb] breakout failed', err)
1886
+ }
1887
+ this.#breakoutState = 'done' // resolved — stop the shimmer, show the result, stay disabled
1888
+ this.update()
1889
+ setTimeout(() => { this.#breakoutState = 'idle'; this.update() }, 2500)
1890
+ }
1891
+
1892
+ // Source listing behind the code toggle: the .bulb.md (minus frontmatter) rendered as markdown
1893
+ // via the plain instance — file-label bars + fenced code.
1894
+ #codeView() {
1895
+ return div({ class: ['bulb-code', 'md'], key: 'code', tabIndex: 0,
1896
+ // Ctrl/⌘-A scopes select-all to the code (a focusable div, so it claims the key).
1897
+ onKeyDown: (e: KeyboardEvent) => {
1898
+ if (!((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A'))) return
1899
+ e.preventDefault()
1900
+ const sel = getSelection()
1901
+ if (!sel) return
1902
+ const range = document.createRange()
1903
+ range.selectNodeContents(e.currentTarget as HTMLElement)
1904
+ sel.removeAllRanges()
1905
+ sel.addRange(range)
1906
+ },
1907
+ onMounted: (el: Element) => { el.innerHTML = mdPlain.render(stripFrontmatter(this.#source)) } })
1908
+ }
1909
+ }
1910
+
1911
+ // Copy-to-clipboard button with a brief "copied" colour flash. Its own Component so domeleon keeps
1912
+ // its DOM node stable across re-renders — that stability is what lets the CSS colour transition run.
1913
+ // Instances live in MessageList.copyButtons (a public array) so domeleon discovers them; an inline
1914
+ // button() re-emitted each render would be recreated and couldn't transition.
1915
+ class CopyButton extends Component {
1916
+ done = false
1917
+ #text: string
1918
+
1919
+ constructor(text: string) {
1920
+ super()
1921
+ this.#text = text
1922
+ }
1923
+
1924
+ setText(text: string) { this.#text = text }
1925
+
1926
+ flash() {
1927
+ navigator.clipboard?.writeText(this.#text)
1928
+ this.done = true
1929
+ this.update()
1930
+ setTimeout(() => { this.done = false; this.update() }, 600)
1931
+ }
1932
+
1933
+ view() {
1934
+ return button({
1935
+ class: ['overlay-pill', 'copy', this.done ? 'done' : ''],
1936
+ onClick: (e: MouseEvent) => { e.stopPropagation(); this.flash() },
1937
+ }, this.done ? 'copied' : 'copy')
1938
+ }
1939
+ }
1940
+
1941
+ // Messages panel: scrolling area, bubbles, expanded-tool set, sticky-bottom.
1942
+ // No optimistic render — the bulb drives nothing; turns appear only once CC
1943
+ // writes them to the JSONL.
1944
+ class MessageList extends Component {
1945
+ messages: Msg[] = []
1946
+ openTools = new Set<string>() // tool ids whose body is expanded
1947
+ expandedTurns = new Set<number>() // turn indices the user expanded past the collapsed summary
1948
+ copyButtons: CopyButton[] = [] // public so domeleon discovers these child components
1949
+ bulbEmbeds: BulbEmbed[] = [] // ditto — one per live ````bulb```` embed across the transcript
1950
+ scrollEl?: HTMLElement
1951
+
1952
+ #idSeq = 0
1953
+ #embedSeq = 0
1954
+ #stuckToBottom = true
1955
+
1956
+ get parent() { return this.ctx.parent as unknown as Root }
1957
+
1958
+ // ===== Public mutators called by Root =====
1959
+
1960
+ clear() {
1961
+ this.messages = []
1962
+ this.expandedTurns.clear()
1963
+ this.copyButtons = []
1964
+ this.bulbEmbeds = []
1965
+ }
1966
+
1967
+ // A copy button registered in copyButtons so domeleon discovers/manages it.
1968
+ #makeCopy(text: string): CopyButton {
1969
+ const copy = new CopyButton(text)
1970
+ this.copyButtons.push(copy)
1971
+ return copy
1972
+ }
1973
+
1974
+ // Build a Msg with its own copy button (when there's text to copy).
1975
+ #addMessage(m: Omit<Msg, 'copy'>): Msg {
1976
+ const msg: Msg = { ...m, copy: m.text ? this.#makeCopy(m.text) : undefined }
1977
+ this.messages.push(msg)
1978
+ return msg
1979
+ }
1980
+
1981
+ // Set right before navigating to a new chat (session-switch) so the
1982
+ // next render lands at the bottom of the new transcript rather than carrying
1983
+ // over the previous scroll position.
1984
+ stickToBottomNextRender() {
1985
+ this.#stuckToBottom = true
1986
+ }
1987
+
1988
+ // ===== apply() dispatch =====
1989
+
1990
+ applyUser(e: Extract<ServerEvent, { type: 'user' }>) {
1991
+ // Consecutive user sends (no assistant turn between) are one intent split
1992
+ // across messages — fold into a single bubble (segments, divided by a rule in
1993
+ // userMarkdown), not a stack of turns. An assistant message between resets it.
1994
+ const prev = this.messages[this.messages.length - 1]
1995
+ if (prev && prev.role === 'user') {
1996
+ prev.segments = [...(prev.segments ?? [prev.text]), e.text]
1997
+ prev.text = prev.segments.join('\n\n')
1998
+ if (prev.copy) prev.copy.setText(prev.text)
1999
+ else if (prev.text) prev.copy = this.#makeCopy(prev.text)
2000
+ return
2001
+ }
2002
+ this.#addMessage({ id: ++this.#idSeq, role: 'user', text: e.text, thinking: '', tools: [] })
2003
+ }
2004
+
2005
+ applyAssistant(e: Extract<ServerEvent, { type: 'assistant' }>) {
2006
+ const msg = this.#addMessage({
2007
+ id: ++this.#idSeq,
2008
+ role: 'assistant',
2009
+ text: e.text,
2010
+ thinking: e.thinking,
2011
+ tools: e.tools.map(t => ({ ...t, isError: false })),
2012
+ })
2013
+ this.#attachEmbeds(msg, e.text)
2014
+ // Auto-expand live edits; leave historical (replayed) ones collapsed.
2015
+ if (e.live) {
2016
+ for (const t of e.tools) {
2017
+ if (EDIT_TOOLS.has(t.name)) this.openTools.add(t.id)
2018
+ }
2019
+ }
2020
+ }
2021
+
2022
+ // Live ````bulb```` embeds: split the text into markdown chunks + bulb sources, turning each
2023
+ // source into a BulbEmbed registered in bulbEmbeds (so domeleon manages it) and stored on
2024
+ // msg.body for #renderBody. Only messages with a bulb fence get a body; the rest stay plain.
2025
+ #attachEmbeds(msg: Msg, text: string) {
2026
+ const segs = splitBulbSegments(text)
2027
+ if (!segs.some(s => s.kind === 'bulb')) return
2028
+ msg.body = segs.map(s => {
2029
+ if (s.kind === 'md') return s.text
2030
+ const embed = new BulbEmbed(s.source, `embed-${this.#embedSeq++}`)
2031
+ this.bulbEmbeds.push(embed)
2032
+ return embed
2033
+ })
2034
+ }
2035
+
2036
+ applyToolResult(e: Extract<ServerEvent, { type: 'tool_result' }>) {
2037
+ const t = this.#findTool(e.id)
2038
+ if (t) { t.result = e.content; t.isError = e.isError }
2039
+ }
2040
+
2041
+ #findTool(id: string): Tool | undefined {
2042
+ return this.messages.flatMap(m => m.tools).find(t => t.id === id)
2043
+ }
2044
+
2045
+ // ===== Scroll behavior =====
2046
+
2047
+ scrollSoon() {
2048
+ requestAnimationFrame(() => {
2049
+ if (this.scrollEl && this.#stuckToBottom) this.scrollEl.scrollTop = this.scrollEl.scrollHeight
2050
+ })
2051
+ }
2052
+
2053
+ // Sticky-bottom: a scroll-up past 50px from the bottom disengages autoscroll
2054
+ // until the user scrolls back within it. Programmatic scrolls land at
2055
+ // scrollHeight, so they keep stuck=true.
2056
+ onScroll() {
2057
+ const el = this.scrollEl
2058
+ if (!el) return
2059
+ this.#stuckToBottom = el.scrollHeight - (el.scrollTop + el.clientHeight) < 50
2060
+ }
2061
+
2062
+ // ===== Views =====
2063
+
2064
+ view() {
2065
+ return div({
2066
+ class: 'messages',
2067
+ onScroll: () => this.onScroll(),
2068
+ // onMounted only to capture the element — scrollSoon() needs the real node to drive scrollTop.
2069
+ onMounted: (el: Element) => { this.scrollEl = el as HTMLElement },
2070
+ },
2071
+ this.parent.ready ? this.renderMessages() : div({ class: 'note' }, 'Connecting…'),
2072
+ )
2073
+ }
2074
+
2075
+ // Group the flat list into turns (a user message + the assistant bubbles that
2076
+ // follow it), then render each. The turn index cycles a palette CSS class for
2077
+ // the left-edge stripe.
2078
+ renderMessages() {
2079
+ const groups: { idx: number; msgs: Msg[] }[] = []
2080
+ let turn = -1
2081
+ for (const msg of this.messages) {
2082
+ if (msg.role === 'user' || groups.length === 0) {
2083
+ if (msg.role === 'user') turn++
2084
+ groups.push({ idx: Math.max(0, turn), msgs: [msg] }) // clamp orphan-assistant to slot 0
2085
+ } else {
2086
+ groups[groups.length - 1]!.msgs.push(msg)
2087
+ }
2088
+ }
2089
+ return groups.flatMap((g, gi) => this.renderTurn(g.msgs, g.idx, gi === groups.length - 1))
2090
+ }
2091
+
2092
+ // A completed turn collapses its intermediate assistant bubbles (everything
2093
+ // but the final answer) under a one-line summary; the user prompt and the last
2094
+ // assistant message stay expanded. The in-flight (last) turn never collapses —
2095
+ // you're watching it stream — and a turn the user clicked open expands fully.
2096
+ // Rationale: the agent's final message already IS its own summary of the turn,
2097
+ // so surfacing it (free, exact) beats any generated paraphrase. See the spec.
2098
+ renderTurn(msgs: Msg[], turnIdx: number, isLast: boolean) {
2099
+ const assistants = msgs.filter(m => m.role === 'assistant')
2100
+ if (isLast || assistants.length < 2) return msgs.map(m => this.bubble(m, turnIdx))
2101
+
2102
+ const expanded = this.expandedTurns.has(turnIdx)
2103
+ const hidden = assistants.slice(0, -1)
2104
+ const last = assistants[assistants.length - 1]!
2105
+ const out = msgs.filter(m => m.role === 'user').map(m => this.bubble(m, turnIdx))
2106
+ out.push(this.turnSummary(hidden, turnIdx, expanded))
2107
+ if (expanded) for (const m of hidden) out.push(this.bubble(m, turnIdx))
2108
+ out.push(this.bubble(last, turnIdx))
2109
+ return out
2110
+ }
2111
+
2112
+ // Collapsed-turn header: caret + data-derived step tally, click to toggle.
2113
+ // It's a .bubble so it inherits the turn stripe and keeps the color continuous.
2114
+ turnSummary(hidden: Msg[], turnIdx: number, expanded: boolean) {
2115
+ return div({ class: ['bubble', 'assistant', turnClassFor(turnIdx)], key: `summary-${turnIdx}` },
2116
+ div({
2117
+ class: 'turn-summary',
2118
+ onClick: () => {
2119
+ if (expanded) this.expandedTurns.delete(turnIdx); else this.expandedTurns.add(turnIdx)
2120
+ this.update()
2121
+ },
2122
+ },
2123
+ span({ class: 'tool-caret' }, expanded ? '▾' : '▸'),
2124
+ span({ class: 'turn-summary-text' }, summarizeSteps(hidden)),
2125
+ ),
2126
+ )
2127
+ }
2128
+
2129
+ bubble(msg: Msg, turnIdx: number) {
2130
+ // Tools-only bubbles sit tighter (CSS adjacent-sibling rule) so a chain of
2131
+ // tool steps doesn't waste vertical space.
2132
+ const toolsOnly = msg.role === 'assistant' && !msg.text && !msg.thinking && msg.tools.length > 0
2133
+ return div({ class: ['bubble', msg.role, toolsOnly ? 'tools-only' : '', turnClassFor(turnIdx)], key: msg.id },
2134
+ msg.thinking ? details({ class: 'thinking' }, summary('thinking'), pre(msg.thinking)) : null,
2135
+ this.#renderBody(msg),
2136
+ msg.tools.map(t => this.tool(t)),
2137
+ msg.copy ? msg.copy.view() : null,
2138
+ )
2139
+ }
2140
+
2141
+ // The message body. With live ````bulb```` embeds it's an ordered run of markdown chunks and
2142
+ // BulbEmbed components (msg.body); otherwise the single markdown div — user prompts through
2143
+ // userMarkdown (clickable @mentions, merged sends), assistant text through renderMarkdown.
2144
+ #renderBody(msg: Msg) {
2145
+ if (msg.body) {
2146
+ return msg.body.map((seg, i) =>
2147
+ typeof seg === 'string'
2148
+ ? (seg.trim() ? this.#mdDiv(`md-${msg.id}-${i}`, renderMarkdown(seg)) : null)
2149
+ : seg.view())
2150
+ }
2151
+ if (!msg.text) return null
2152
+ return this.#mdDiv(`md-${msg.id}`, msg.role === 'user' ? userMarkdown(msg) : renderMarkdown(msg.text))
2153
+ }
2154
+
2155
+ #mdDiv(key: string, mount: (el: Element) => void) {
2156
+ return div({ class: 'md', key, onMounted: mount })
2157
+ }
2158
+
2159
+ tool(t: Tool) {
2160
+ const open = this.openTools.has(t.id)
2161
+ const filePath = filePathOf(t.name, t.input)
2162
+ const sum = filePath ?? toolSummary(t.input)
2163
+ // Hand-rolled toggle, not <details>/<summary>: Chrome swallows custom-scheme
2164
+ // (vscode://) anchor clicks inside <summary>.
2165
+ return div({ class: ['tool', t.isError ? 'err' : '', open ? 'open' : ''] },
2166
+ div({
2167
+ class: 'tool-head',
2168
+ onClick: () => {
2169
+ if (open) this.openTools.delete(t.id); else this.openTools.add(t.id)
2170
+ this.update()
2171
+ },
2172
+ },
2173
+ span({ class: 'tool-caret' }, open ? '▾' : '▸'),
2174
+ // Verb + summary wrap together so inline flow baseline-aligns them across
2175
+ // their two fonts (see .tool-label).
2176
+ div({ class: 'tool-label' },
2177
+ span({ class: 'tool-name' }, t.name),
2178
+ sum
2179
+ ? (filePath
2180
+ ? a({
2181
+ class: 'tool-sum link',
2182
+ title: filePath,
2183
+ onClick: (e: MouseEvent) => {
2184
+ e.preventDefault()
2185
+ e.stopPropagation() // don't toggle row
2186
+ tb.server.openFile(filePath)
2187
+ },
2188
+ }, filePath)
2189
+ : span({ class: 'tool-sum' }, sum))
2190
+ : null,
2191
+ ),
2192
+ t.result === undefined ? span({ class: 'tool-run' }, '…') : null,
2193
+ ),
2194
+ open ? this.toolBody(t) : null,
2195
+ open && t.result !== undefined ? pre({ class: 'tool-out' }, t.result.slice(0, 4000)) : null,
2196
+ )
2197
+ }
2198
+
2199
+ // Diff view for editing tools; JSON for everything else.
2200
+ toolBody(t: Tool) {
2201
+ const hunks = diffHunks(t)
2202
+ if (!hunks) return pre({ class: 'tool-in' }, JSON.stringify(t.input, null, 2))
2203
+ return div({ class: 'diff' },
2204
+ ...hunks.flatMap((h, i) => [
2205
+ hunks.length > 1 ? div({ class: 'diff-step' }, `edit ${i + 1}/${hunks.length}`) : null,
2206
+ h.old ? pre({ class: 'diff-old' }, String(h.old)) : null,
2207
+ h.new ? pre({ class: 'diff-new' }, String(h.new)) : null,
2208
+ ]),
2209
+ )
2210
+ }
2211
+ }
2212
+
2213
+ class Root extends Component {
2214
+ ready = false
2215
+ cwd = ''
2216
+ sessionId = ''
2217
+ sessionPicker = new SessionPicker()
2218
+ tokenPill = new TokenPill()
2219
+ bulbsPill = new BulbsPill()
2220
+ messageList = new MessageList()
2221
+ tokens = { in: 0, out: 0, cached: 0, cacheCreate: 0 }
2222
+ working = false // CC is mid-turn (live-chain leaf unresolved); from poll()
2223
+ ownPid = 0 // this host server's pid; the bulbs pill excludes it
2224
+
2225
+ #cursor = 0
2226
+ #polling = false
2227
+ #started = false
2228
+
2229
+ override onAttached() {
2230
+ if (this.#started) return
2231
+ this.#started = true
2232
+ this.init()
2233
+ }
2234
+
2235
+ // Only one status-bar popup open at a time: a pill calls this as it opens so any other
2236
+ // open popup is dismissed (each pill's onClick stops propagation, so the rivals' outside-
2237
+ // click closers never fire on their own).
2238
+ closePopups(except?: unknown) {
2239
+ if (this.sessionPicker !== except) this.sessionPicker.close()
2240
+ if (this.tokenPill !== except) this.tokenPill.close()
2241
+ if (this.bulbsPill !== except) this.bulbsPill.close()
2242
+ }
2243
+
2244
+ async init() {
2245
+ const i = await tb.server.info()
2246
+ this.cwd = i.cwd
2247
+ this.ownPid = i.pid ?? 0
2248
+ this.ready = true
2249
+ this.updateTitle()
2250
+ this.update()
2251
+ this.pump()
2252
+ }
2253
+
2254
+ // Tab title "<session preview> — <cwd basename>" so multiple bulbs are
2255
+ // distinguishable; falls back to the cwd basename, then "Claude Bulb".
2256
+ updateTitle() {
2257
+ const base = basename(this.cwd)
2258
+ const preview = this.sessionPicker.currentPreview()
2259
+ const short = truncate(preview, 40)
2260
+ document.title = short && base ? `${short} — ${base}`
2261
+ : short ? short
2262
+ : base ? `Claude Bulb — ${base}`
2263
+ : 'Claude Bulb'
2264
+ }
2265
+
2266
+ // Poll the buffer every 600ms; the terminal drives turns, so entries pop in
2267
+ // whenever CC flushes a line (no live streaming signal to chase).
2268
+ pump() {
2269
+ if (this.#polling) return
2270
+ this.#polling = true
2271
+ const tick = async () => {
2272
+ try {
2273
+ const { events, cursor, working } = await tb.server.poll(this.#cursor)
2274
+ this.#cursor = cursor
2275
+ for (const e of events) this.apply(e)
2276
+ const workingChanged = working !== this.working
2277
+ this.working = working
2278
+ if (events.length || workingChanged) this.update()
2279
+ if (events.length) this.messageList.scrollSoon()
2280
+ } catch (err) {
2281
+ console.error('[claude-bulb] poll failed', err)
2282
+ }
2283
+ setTimeout(tick, 600)
2284
+ }
2285
+ tick()
2286
+ }
2287
+
2288
+ apply(e: ServerEvent) {
2289
+ switch (e.type) {
2290
+ case 'cleared':
2291
+ this.messageList.clear()
2292
+ this.tokens = { in: 0, out: 0, cached: 0, cacheCreate: 0 }
2293
+ break
2294
+ case 'session': this.sessionId = e.sessionId; this.updateTitle(); break
2295
+ case 'user': this.messageList.applyUser(e); break
2296
+ case 'assistant': this.messageList.applyAssistant(e); break
2297
+ case 'tool_result': this.messageList.applyToolResult(e); break
2298
+ case 'usage': this.tokens = { in: e.in, out: e.out, cached: e.cached, cacheCreate: e.cacheCreate }; break
2299
+ }
2300
+ }
2301
+
2302
+ view() {
2303
+ return div({ class: 'app' },
2304
+ this.messageList.view(),
2305
+ this.statusbar(),
2306
+ )
2307
+ }
2308
+
2309
+ // Bottom strip: a right-aligned cluster of working indicator, session switcher,
2310
+ // token count (working sits leftmost of the three).
2311
+ statusbar() {
2312
+ return div({ class: 'statusbar' },
2313
+ div({ class: 'statusbar-actions' },
2314
+ this.working ? div({ class: 'working' }, span({ class: 'working-dot' }), 'working…') : null,
2315
+ this.bulbsPill.view(),
2316
+ this.sessionPicker.view(),
2317
+ this.tokenPill.view(),
2318
+ ),
2319
+ )
2320
+ }
2321
+ }
2322
+
2323
+ // Override the CLI's `<name> - typebulb` default tab title.
2324
+ document.title = 'Claude Bulb'
2325
+
2326
+ new App({ root: new Root(), id: 'app' })
2327
+ ```
2328
+
2329
+ **styles.css**
2330
+
2331
+ ```css
2332
+ :root {
2333
+ /* Light is the default; data-theme="dark" is set by the host (typebulb.com
2334
+ or the typebulb CLI, sourced from prefers-color-scheme). */
2335
+ --bg: rgb(255, 255, 255);
2336
+ --fg: rgb(28, 28, 30);
2337
+ --muted: rgb(96, 98, 106);
2338
+ --panel: rgb(248, 249, 251);
2339
+ --border: rgb(224, 224, 228);
2340
+ --accent: rgb(58, 125, 232);
2341
+ --user-bg: rgb(243, 243, 245);
2342
+ --tool-bg: rgb(245, 246, 248);
2343
+ --err: rgb(206, 60, 60);
2344
+ --diff-add: rgb(40, 140, 70);
2345
+ /* Prose measure for text (bubbles + notes) — kept to a readable line length.
2346
+ Diagrams break out wider than this; see .md .mermaid. */
2347
+ --content-max: 800px;
2348
+ color-scheme: light;
2349
+ }
2350
+
2351
+ html[data-theme="dark"] {
2352
+ --bg: rgb(20, 20, 22);
2353
+ --fg: rgb(232, 232, 236);
2354
+ --muted: rgb(150, 152, 160);
2355
+ --panel: rgb(30, 30, 32);
2356
+ --border: rgb(54, 54, 60);
2357
+ --accent: rgb(122, 162, 250);
2358
+ --user-bg: rgb(40, 40, 44);
2359
+ --tool-bg: rgb(30, 31, 36);
2360
+ --err: rgb(240, 120, 120);
2361
+ --diff-add: rgb(120, 210, 140);
2362
+ color-scheme: dark;
2363
+ }
2364
+
2365
+ /* Aliases for beautiful-mermaid, indirecting around a var-name collision with the
2366
+ bulb's theme (see the renderMermaidSVG call). They still resolve to the live
2367
+ theme at the use site. */
2368
+ :root {
2369
+ --mm-bg: var(--bg);
2370
+ --mm-fg: var(--fg);
2371
+ --mm-line: var(--muted);
2372
+ --mm-accent: var(--accent);
2373
+ --mm-muted: var(--muted);
2374
+ --mm-surface: var(--tool-bg);
2375
+ --mm-border: var(--border);
2376
+ }
2377
+
2378
+ * { box-sizing: border-box; }
2379
+
2380
+ body {
2381
+ margin: 0;
2382
+ height: 100vh;
2383
+ overflow: hidden;
2384
+ color: var(--fg);
2385
+ background: var(--bg);
2386
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
2387
+ font-size: 15px;
2388
+ line-height: 1.55;
2389
+ }
2390
+
2391
+ /* position:relative anchors the overlaid (absolute) statusbar to the viewport box. */
2392
+ .app { position: relative; display: flex; flex-direction: column; height: 100vh; }
2393
+
2394
+ /* Bottom status strip, overlaid (not in flow) on the chat — which runs
2395
+ full-height to the very bottom. The strip itself has NO fill, so the message
2396
+ text shows through everywhere, right to the bottom edge. Only the items (the
2397
+ pills) are opaque, so they overlay the text with visual precedence rather than
2398
+ the strip cutting it off with a band. No border — the typebulb host's rounded
2399
+ card is the only frame. */
2400
+ .statusbar {
2401
+ position: absolute;
2402
+ left: 0;
2403
+ right: 0;
2404
+ bottom: 0;
2405
+ display: flex;
2406
+ align-items: center;
2407
+ gap: .65rem;
2408
+ /* .4rem inset on top/left/bottom; the right is larger to clear the scrollbar
2409
+ the statusbar overlays — so the *visible* gap to the content edge matches the
2410
+ other sides instead of the pills sitting flush against the scrollbar. */
2411
+ padding: .4rem 1.35rem .4rem .4rem;
2412
+ font-size: .8rem;
2413
+ /* Drop the inherited 1.55 line-height so the bar's own strut doesn't add
2414
+ phantom ascender/descender room above the pills. Everything in the bar
2415
+ is a fixed-height pill or a shimmer text line — both look right at 1. */
2416
+ line-height: 1;
2417
+ min-width: 0;
2418
+ }
2419
+ .statusbar-actions {
2420
+ margin-left: auto;
2421
+ display: flex;
2422
+ gap: .4rem;
2423
+ align-items: center;
2424
+ }
2425
+ /* Chip shape shared by the status-bar pills (interactive: session chip, token
2426
+ counter) and the working indicator (passive). One rule = one source of vertical
2427
+ alignment, no element-type drift between siblings; inline-flex + line-height:1
2428
+ + fixed height centers the glyph run by the box, not by the inherited line-box.
2429
+ Opaque so they read cleanly over the message text the transparent strip lets
2430
+ through. */
2431
+ .pill, .working {
2432
+ display: inline-flex;
2433
+ align-items: center;
2434
+ justify-content: center;
2435
+ line-height: 1;
2436
+ height: 1.7rem;
2437
+ padding: 0 .7rem;
2438
+ border-radius: 7px;
2439
+ background: var(--panel);
2440
+ border: 1px solid var(--border);
2441
+ color: var(--muted);
2442
+ font-size: .8rem;
2443
+ white-space: nowrap;
2444
+ user-select: none;
2445
+ }
2446
+ /* Pills are <button>s: reset UA chrome (font/appearance/margin) and add the
2447
+ interactive affordance. Slightly larger text than the passive indicator. */
2448
+ .pill {
2449
+ font: inherit;
2450
+ font-size: .85rem;
2451
+ appearance: none;
2452
+ margin: 0;
2453
+ cursor: pointer;
2454
+ }
2455
+ .pill:hover { border-color: var(--accent); color: var(--accent); }
2456
+
2457
+ /* The two status-bar chips each anchor an upward popover (token breakdown /
2458
+ session list). Wrap = positioning context; the popover anchors bottom/right and
2459
+ grows up-and-left — the chips sit in the right-aligned cluster, so left:0 would
2460
+ run them off the viewport's right edge. */
2461
+ .token-wrap, .sid-wrap, .servers-wrap { position: relative; display: inline-block; }
2462
+ /* Each pill renders its wrap unconditionally, emitting an empty div when it has
2463
+ nothing to show (no tokens / no session / nothing running). An empty wrap is
2464
+ still a flex child, so .statusbar-actions' gap lands on both its sides —
2465
+ doubling the visible gap between its neighbours. Collapse the empties so the
2466
+ cluster spaces evenly regardless of which pills are present. */
2467
+ .token-wrap:empty, .sid-wrap:empty, .servers-wrap:empty { display: none; }
2468
+ .token-pop, .picker, .servers-pop {
2469
+ position: absolute;
2470
+ bottom: calc(100% + .35rem);
2471
+ right: 0;
2472
+ z-index: 10;
2473
+ background: var(--panel);
2474
+ border: 1px solid var(--border);
2475
+ border-radius: 10px;
2476
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
2477
+ }
2478
+ .token-pop {
2479
+ min-width: 360px;
2480
+ max-width: 92vw;
2481
+ padding: .35rem;
2482
+ font-size: .85rem;
2483
+ }
2484
+ /* Each row: left text block, number pushed to the right edge. Sizing/rhythm matches .picker-row
2485
+ (font .85rem, row padding .45rem .55rem) so the three status-bar popovers read as one family. */
2486
+ .token-line {
2487
+ display: flex;
2488
+ justify-content: space-between;
2489
+ align-items: baseline;
2490
+ gap: .6rem;
2491
+ padding: .45rem .55rem;
2492
+ }
2493
+ .token-line-title { font-weight: 600; color: var(--fg); margin-right: .5rem; }
2494
+ .token-line-lbl { color: var(--muted); font-size: .72rem; }
2495
+ .token-line-num { color: var(--fg); font-variant-numeric: tabular-nums; white-space: nowrap; }
2496
+
2497
+ /* "working…" indicator: CC is mid-turn. Passive — driven by the live-chain leaf,
2498
+ not a live token stream (the JSONL is flushed in batches, so this can sit lit
2499
+ through a multi-minute buffering window). Shares the chip shape above; only adds
2500
+ room for the pulsing dot. Non-interactive — no cursor / hover accent. */
2501
+ .working { gap: .45rem; }
2502
+ .working-dot {
2503
+ width: 7px;
2504
+ height: 7px;
2505
+ border-radius: 50%;
2506
+ background: var(--accent);
2507
+ animation: working-pulse 1.4s ease-in-out infinite;
2508
+ }
2509
+ @keyframes working-pulse { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
2510
+ @media (prefers-reduced-motion: reduce) { .working-dot { animation: none; opacity: .8; } }
2511
+
2512
+ /* Session list — shares the popover chrome (.token-pop, .picker above); these are
2513
+ just its own dimensions. */
2514
+ .picker {
2515
+ width: min(440px, calc(100vw - 2rem));
2516
+ max-height: 60vh;
2517
+ overflow: hidden;
2518
+ padding: .35rem;
2519
+ display: flex;
2520
+ flex-direction: column;
2521
+ gap: .15rem;
2522
+ }
2523
+ /* The filter (.bulb-filter-control) pins at the top; the row list scrolls inside the capped box —
2524
+ same shape as the launcher's .servers-pop / .bulb-list. */
2525
+ .picker-list { display: flex; flex-direction: column; gap: .15rem; flex: 1; min-height: 0; overflow-y: auto; }
2526
+ .picker-empty { padding: .8rem; color: var(--muted); font-size: .85rem; text-align: center; }
2527
+ .picker-row {
2528
+ display: flex;
2529
+ gap: .6rem;
2530
+ align-items: baseline;
2531
+ padding: .45rem .55rem;
2532
+ border-radius: 6px;
2533
+ cursor: pointer;
2534
+ border: 1px solid transparent;
2535
+ font-size: .85rem;
2536
+ }
2537
+ .picker:focus { outline: none; } /* the list owns its own highlight; no focus ring */
2538
+ .picker-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
2539
+ /* Keyboard cursor (arrow keys / hover) — matches the bulb picker's .active row. */
2540
+ .picker-row.active {
2541
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
2542
+ border-color: color-mix(in srgb, var(--accent) 40%, transparent);
2543
+ }
2544
+ /* The attached session — a leading accent dot in its own gutter slot, reserved (transparent) on
2545
+ every row so titles align whether or not it's shown, and filled only on the attached row so it
2546
+ reads as "you are here" even when the cursor has moved off. */
2547
+ .picker-dot {
2548
+ flex: none;
2549
+ align-self: center;
2550
+ width: 7px;
2551
+ height: 7px;
2552
+ border-radius: 50%;
2553
+ background: transparent;
2554
+ }
2555
+ .picker-row.current .picker-dot { background: var(--accent); }
2556
+ .picker-preview {
2557
+ flex: 1; min-width: 0;
2558
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
2559
+ color: var(--fg);
2560
+ }
2561
+ .picker-time {
2562
+ color: var(--muted);
2563
+ font-size: .72rem;
2564
+ min-width: 2.6rem; text-align: right;
2565
+ }
2566
+
2567
+ /* Running-breakouts list — shares the popover chrome (.servers-pop above); its own dimensions +
2568
+ per-row layout: the play/stop button + name read together on the left (▶/■ Title, name flex:1),
2569
+ and the metadata/controls form a right-aligned cluster of fixed-width columns (logs · trust ·
2570
+ time/port) that line up row-to-row — right-anchored, so the rightmost (time/port) always aligns and
2571
+ the rest stack inward (logs and time/port share a width; trust is wider, to fit "restricted"). The
2572
+ box is a FIXED size shared with the drilled-in console (.log-mode below): flipping list⇄console
2573
+ swaps only the contents, not the frame, so it doesn't jump. The filter pins to the top and the list
2574
+ scrolls inside (see .bulb-list). */
2575
+ .servers-pop {
2576
+ width: min(560px, calc(100vw - 2rem));
2577
+ height: min(66vh, 560px);
2578
+ overflow: hidden;
2579
+ padding: .35rem;
2580
+ display: flex;
2581
+ flex-direction: column;
2582
+ gap: .15rem;
2583
+ }
2584
+ .server-row {
2585
+ /* Subgrid: all rows share ONE set of column tracks (defined on .bulb-list). A per-row grid sizes
2586
+ its columns independently — which is exactly what broke alignment (a stopped row with no
2587
+ logs/port sized its tracks differently and its trust toggle drifted right). Spanning the parent's
2588
+ tracks instead makes trust/logs/time line up row-to-row, while the parent's auto columns size to
2589
+ the widest content so the cluster stays tight (no reserved min-width slack). Absent trust/logs
2590
+ render an empty cell that still holds its column. */
2591
+ display: grid;
2592
+ grid-template-columns: subgrid;
2593
+ grid-column: 1 / -1;
2594
+ align-items: center;
2595
+ /* Inset rounded highlight, matching the session picker. The background is the subgrid track area
2596
+ (not bled out to the edge — that ran it under the scrollbar on the right while staying clear on
2597
+ the left, an asymmetry). Text gets its breathing room inside the highlight via end-item margins
2598
+ (.server-stop/.bulb-launch on the left, .server-port/.bulb-time on the right) rather than row
2599
+ padding, which would shift the subgrid tracks and break alignment. */
2600
+ padding: .15rem 0;
2601
+ border-radius: 6px;
2602
+ font-size: .85rem;
2603
+ }
2604
+ .server-row:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
2605
+ /* Keyboard-highlighted row (arrow-key cursor) — a touch stronger than hover so it stands out. */
2606
+ .server-row.active { background: color-mix(in srgb, var(--accent) 16%, transparent); }
2607
+ .server-name {
2608
+ flex: 1; min-width: 0;
2609
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
2610
+ color: var(--accent); text-decoration: none; cursor: pointer;
2611
+ }
2612
+ .server-name:hover { text-decoration: underline; }
2613
+ /* Port is a link to the running server (localhost:<port>), same target as the name. */
2614
+ .server-port { color: var(--muted); font-size: .76rem; font-variant-numeric: tabular-nums; text-decoration: none; text-align: right; margin-right: .4rem; }
2615
+ a.server-port:hover { color: var(--accent); text-decoration: underline; }
2616
+ /* Logs reads like a link (flips to this server's console), not a button. */
2617
+ .server-logs { color: var(--muted); font-size: .76rem; text-decoration: none; cursor: pointer; margin-left: .5rem; }
2618
+ .server-logs:hover, .server-logs.on { color: var(--accent); text-decoration: underline; }
2619
+ /* Launcher filter box — pinned above the list; type to narrow (matches name + path). The control
2620
+ wrapper pins to the top and anchors the clear (×) at the far right (position:relative + the
2621
+ input's right padding reserves its room). */
2622
+ .bulb-filter-control { position: relative; flex: none; margin-bottom: .35rem; }
2623
+ .bulb-filter {
2624
+ font: inherit;
2625
+ font-size: .82rem;
2626
+ width: 100%;
2627
+ box-sizing: border-box;
2628
+ padding: .35rem 1.7rem .35rem .5rem;
2629
+ border-radius: 6px;
2630
+ background: var(--bg);
2631
+ border: 1px solid var(--border);
2632
+ color: var(--fg);
2633
+ }
2634
+ .bulb-filter::placeholder { color: var(--muted); }
2635
+ .bulb-filter:focus { outline: none; border-color: var(--accent); }
2636
+ /* Clear (×) — only rendered when the filter is non-empty; wipes it and refocuses the box. */
2637
+ .bulb-filter-clear {
2638
+ position: absolute; right: .3rem; top: 50%; transform: translateY(-50%);
2639
+ appearance: none; border: none; background: transparent; cursor: pointer;
2640
+ color: var(--muted); font-size: 1.05rem; line-height: 1; padding: 0 .3rem; border-radius: 4px;
2641
+ }
2642
+ .bulb-filter-clear:hover { color: var(--accent); }
2643
+ /* Scrolls within the fixed-height popover so the filter stays put and the box never grows. */
2644
+ /* The shared grid for the launcher rows: each .server-row is a subgrid spanning these tracks, so
2645
+ columns line up row-to-row. auto cols size to their widest content (tight, no slack); 1fr is the
2646
+ name. A small right padding keeps the selected-row highlight off the scrollbar. */
2647
+ .bulb-list { display: grid; grid-template-columns: auto 1fr auto auto auto; column-gap: .6rem; row-gap: .05rem; align-items: center; align-content: start; padding-right: 4px; flex: 1; min-height: 0; overflow-y: auto; }
2648
+ /* A stopped bulb's name isn't a link (no running URL), so it stays plain foreground. */
2649
+ .server-name.stopped { color: var(--fg); }
2650
+ .bulb-time { color: var(--muted); font-size: .72rem; font-variant-numeric: tabular-nums; text-align: right; margin-right: .4rem; }
2651
+ /* The row's chrome buttons share one reset; sizing + colour differ below. Play/stop are round icon
2652
+ buttons (friendlier, space-economic); the trust switch is text. Logs is a link, not a button
2653
+ (see .server-logs above), so it's not in this group. */
2654
+ .server-stop, .bulb-launch, .trust-toggle {
2655
+ font: inherit; appearance: none; margin: 0; cursor: pointer; box-sizing: border-box;
2656
+ background: transparent; border: 1px solid var(--border); border-radius: 5px;
2657
+ height: 1.7rem; display: inline-flex; align-items: center; justify-content: center;
2658
+ }
2659
+ .trust-toggle { font-size: .76rem; padding: 0 .5rem; color: var(--muted); justify-self: center; }
2660
+ .trust-toggle:hover, .trust-toggle.on { color: var(--accent); border-color: var(--accent); }
2661
+ .server-stop, .bulb-launch { width: 1.7rem; padding: 0; border-radius: 50%; margin-left: .4rem; }
2662
+ .btn-icon { display: block; }
2663
+ /* A stopped bulb's play button is dormant — the title is the primary launch target — so it's
2664
+ hidden until the row is hovered or keyboard-selected. visibility (not display) reserves its slot,
2665
+ so the name column doesn't shift when it appears. */
2666
+ .bulb-launch { color: var(--accent); visibility: hidden; }
2667
+ .server-row:hover .bulb-launch, .server-row.active .bulb-launch { visibility: visible; }
2668
+ .bulb-launch:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
2669
+ /* Launching: stays visible (hover-independent) and shimmers until the row flips to a stop button,
2670
+ so the ~2s wait reads as "working" rather than a dead click. Transient — driven by the in-memory
2671
+ launching set, never persisted. */
2672
+ .bulb-launch.launching {
2673
+ visibility: visible; border-color: var(--accent);
2674
+ background: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--accent) 35%, transparent) 50%, transparent 70%);
2675
+ background-size: 250% 100%;
2676
+ animation: bulb-shimmer 1.1s linear infinite;
2677
+ }
2678
+ @keyframes bulb-shimmer { from { background-position: 250% 0; } to { background-position: -250% 0; } }
2679
+ @media (prefers-reduced-motion: reduce) {
2680
+ .bulb-launch.launching { animation: none; background: color-mix(in srgb, var(--accent) 18%, transparent); }
2681
+ }
2682
+ .server-stop { color: var(--muted); }
2683
+ .server-stop:hover { color: var(--err); border-color: var(--err); }
2684
+ /* Elevation prompt (VS-Code-Workspace-Trust style) — a modal over the whole view, since a
2685
+ denied capability is worth interrupting for. Backdrop dims; the card holds the choice. */
2686
+ .trust-back {
2687
+ position: fixed; inset: 0; z-index: 50;
2688
+ background: rgba(0, 0, 0, .5);
2689
+ display: flex; align-items: center; justify-content: center;
2690
+ padding: 1rem;
2691
+ }
2692
+ .trust-modal {
2693
+ width: min(420px, 100%);
2694
+ background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
2695
+ box-shadow: 0 12px 48px rgba(0, 0, 0, .5);
2696
+ padding: 1rem 1.1rem; display: flex; flex-direction: column; gap: .6rem;
2697
+ }
2698
+ .trust-modal-h { font-size: 1rem; font-weight: 600; color: var(--fg); }
2699
+ .trust-modal-b { font-size: .85rem; color: var(--fg); line-height: 1.45; }
2700
+ .trust-modal-warn { font-size: .78rem; color: var(--muted); line-height: 1.4; }
2701
+ .trust-modal-acts { display: flex; justify-content: flex-end; gap: .5rem; margin-top: .2rem; }
2702
+ .trust-no, .trust-yes {
2703
+ font: inherit; font-size: .82rem; appearance: none; margin: 0; cursor: pointer;
2704
+ padding: .35rem .8rem; border-radius: 7px; border: 1px solid var(--border); background: transparent; color: var(--fg);
2705
+ }
2706
+ .trust-no:hover { border-color: var(--fg); }
2707
+ .trust-yes {
2708
+ color: var(--accent);
2709
+ border-color: color-mix(in srgb, var(--accent) 55%, transparent);
2710
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
2711
+ }
2712
+ .trust-yes:hover { background: color-mix(in srgb, var(--accent) 22%, transparent); }
2713
+ /* Drilled-in streaming console for one running server. It takes over the whole bulbs
2714
+ popover (the list flips to it; ← back flips home), so it's wider than the list and its
2715
+ body fills the popover's height. overflow:hidden lets the rounded popover border clip the
2716
+ square head/body; the body scrolls internally. */
2717
+ .servers-pop.log-mode {
2718
+ padding: 0; /* same fixed width/height as the list (.servers-pop); only the chrome differs */
2719
+ }
2720
+ .servers-pop.log-mode:focus { outline: none; }
2721
+ .bulb-log-head {
2722
+ display: flex; align-items: center; justify-content: flex-end; gap: .5rem;
2723
+ padding: .3rem .5rem; background: var(--panel);
2724
+ border-bottom: 1px solid var(--border);
2725
+ }
2726
+ .bulb-log-back {
2727
+ font: inherit; font-size: .76rem; appearance: none; margin: 0;
2728
+ padding: .15rem .5rem; border-radius: 5px;
2729
+ background: transparent; border: 1px solid var(--border);
2730
+ color: var(--muted); cursor: pointer; white-space: nowrap;
2731
+ }
2732
+ .bulb-log-back:hover { color: var(--accent); border-color: var(--accent); }
2733
+ .bulb-log-name { min-width: 0; color: var(--muted); font-size: .72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2734
+ .bulb-log-body {
2735
+ flex: 1; min-height: 0;
2736
+ margin: 0; padding: .5rem .6rem;
2737
+ overflow: auto; /* top-anchored normal flow — output reads top→down */
2738
+ background: var(--bg);
2739
+ font: .72rem/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2740
+ color: var(--fg);
2741
+ }
2742
+ .bulb-log-text { white-space: pre-wrap; word-break: break-word; }
2743
+ /* messages */
2744
+ .messages {
2745
+ flex: 1;
2746
+ overflow-y: auto;
2747
+ /* Content runs under the overlaid statusbar rather than stopping above it.
2748
+ Left padding is larger to clear the turn stripe in the left gutter; the
2749
+ bottom is larger still to clear the overlaid statusbar pills (~2.1rem:
2750
+ .4rem inset + 1.7rem pill) so the last line isn't hidden behind them. */
2751
+ padding: 1.25rem 1.25rem 2.5rem 2.25rem;
2752
+ display: flex;
2753
+ flex-direction: column;
2754
+ gap: 1rem;
2755
+ }
2756
+ .note {
2757
+ display: flex;
2758
+ align-items: baseline;
2759
+ gap: .5rem;
2760
+ color: var(--muted);
2761
+ font-size: .9rem;
2762
+ max-width: var(--content-max);
2763
+ width: 100%;
2764
+ margin: 0 auto;
2765
+ }
2766
+ .bubble {
2767
+ position: relative;
2768
+ max-width: var(--content-max);
2769
+ width: 100%;
2770
+ margin: 0 auto;
2771
+ }
2772
+ .bubble.user {
2773
+ background: var(--user-bg);
2774
+ border: 1px solid var(--border);
2775
+ border-radius: 12px;
2776
+ padding: .7rem .9rem;
2777
+ }
2778
+ /* Consecutive tool-only bubbles sit tight via negative margin (flex `gap`
2779
+ can't be overridden per sibling pair). The adjacent-sibling selector leaves
2780
+ the first tools-only bubble's gap intact, preserving the turn boundary. */
2781
+ .bubble.tools-only + .bubble.tools-only { margin-top: -.9rem; }
2782
+
2783
+ /* Turn stripe — a 2px colored bar to the left of each bubble. All bubbles in
2784
+ one user→assistant turn share a color; the palette cycles through 5 so
2785
+ adjacent turns are visually distinct when scrolling. Sits outside the
2786
+ bubble's left edge in the messages container's padding gutter.
2787
+ By default top/bottom extend half a gap beyond the bubble so same-turn
2788
+ neighbours' stripes meet seamlessly across the 1rem flex gap. The two
2789
+ turn-boundary overrides below clip those extensions so a turn's stripe
2790
+ starts at the top of its user bubble and ends at the bottom of its last
2791
+ bubble — leaving the full 2rem inter-turn gap stripe-free as a separator.
2792
+ --border-w compensates the 1px padding-edge offset that user bubbles' border
2793
+ would otherwise introduce — without it, stripes alternate 1px inset between
2794
+ user/assistant rows. */
2795
+ .bubble { --border-w: 0px; }
2796
+ .bubble.user { --border-w: 1px; }
2797
+ .bubble::before {
2798
+ content: '';
2799
+ position: absolute;
2800
+ /* Offsets subtract --border-w: absolute positioning anchors to the
2801
+ padding-edge (inside the border), so a user bubble's 1px border would
2802
+ otherwise shave 1px off each stripe edge. */
2803
+ left: calc(-1rem - var(--border-w));
2804
+ top: calc(-.5rem - var(--border-w));
2805
+ bottom: calc(-.5rem - var(--border-w));
2806
+ width: 2px;
2807
+ background: var(--turn-color, transparent);
2808
+ }
2809
+ /* Clip stripe at turn boundaries. A user bubble always starts a turn → no
2810
+ upward extension. A bubble followed by a user bubble is the last of its
2811
+ turn → no downward extension. Same for the very last bubble in the list
2812
+ (an in-flight turn whose last assistant message is the end of the stream).
2813
+ Same `0 - var(--border-w)` form as the default top/bottom so user bubbles'
2814
+ 1px border still aligns with the stripe edge. */
2815
+ .bubble.user::before { top: calc(0px - var(--border-w)); }
2816
+ .bubble:has(+ .bubble.user)::before,
2817
+ .bubble:last-child::before { bottom: calc(0px - var(--border-w)); }
2818
+ /* Extra gap before each new turn (user bubble preceded by anything). Combined
2819
+ with the container's 1rem gap → ~2rem visible between turns vs ~1rem within
2820
+ a turn. Doesn't fire on the very first bubble (no preceding sibling). */
2821
+ .bubble + .bubble.user { margin-top: 1rem; }
2822
+ .bubble.turn-0 { --turn-color: #4cb35a; } /* green */
2823
+ .bubble.turn-1 { --turn-color: #e89a3c; } /* amber */
2824
+ .bubble.turn-2 { --turn-color: #d36b9a; } /* pink */
2825
+ .bubble.turn-3 { --turn-color: #9970d6; } /* purple */
2826
+ .bubble.turn-4 { --turn-color: #3cb8b3; } /* teal */
2827
+
2828
+ /* rendered markdown */
2829
+ .md { word-break: break-word; }
2830
+ /* Markdown links (often many file:line citations per message) — restrained like
2831
+ the rest of the bulb's links: accent, no persistent underline, reveal it only
2832
+ on hover. Without this they'd carry the browser-default underline-on-every-one. */
2833
+ .md a { color: var(--accent); text-decoration: none; }
2834
+ .md a:hover { text-decoration: underline; }
2835
+ .md :first-child { margin-top: 0; }
2836
+ .md :last-child { margin-bottom: 0; }
2837
+ .md p { margin: .5rem 0; }
2838
+ /* Thematic break — also the divider between merged consecutive user sends (see
2839
+ applyUser); faint so the bubble still reads as one turn. */
2840
+ .md hr {
2841
+ border: none;
2842
+ border-top: 1px solid var(--border);
2843
+ margin: .55rem 0;
2844
+ opacity: .5;
2845
+ }
2846
+ .md pre {
2847
+ background: var(--tool-bg);
2848
+ border: 1px solid var(--border);
2849
+ border-radius: 8px;
2850
+ padding: .7rem .85rem;
2851
+ overflow-x: auto;
2852
+ }
2853
+ /* Blockquote: left bar + faint tint + muted text — a quiet "quoted" callout. The bar
2854
+ is mixed toward --border so it stays distinct from the bright turn stripe in the
2855
+ gutter, and the tint is light enough not to read as a code/output panel. */
2856
+ .md blockquote {
2857
+ margin: .5rem 0;
2858
+ padding: .35rem .85rem;
2859
+ border-left: 3px solid color-mix(in srgb, var(--accent) 50%, var(--border));
2860
+ background: color-mix(in srgb, var(--accent) 6%, transparent);
2861
+ border-radius: 0 6px 6px 0;
2862
+ color: var(--muted);
2863
+ }
2864
+ .md blockquote :first-child { margin-top: 0; }
2865
+ .md blockquote :last-child { margin-bottom: 0; }
2866
+ .md code {
2867
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
2868
+ font-size: .88em;
2869
+ }
2870
+ .md :not(pre) > code {
2871
+ background: var(--tool-bg);
2872
+ padding: .1em .35em;
2873
+ border-radius: 5px;
2874
+ }
2875
+ .md table { border-collapse: collapse; }
2876
+ .md th, .md td { border: 1px solid var(--border); padding: .3rem .55rem; }
2877
+
2878
+ /* Full-lane breakout — a mermaid diagram (the whole element) and a SPREAD bulb's iframe step
2879
+ out of the narrow prose column to the transcript width so wide content (a flowchart, a data
2880
+ table) gets room instead of being squeezed. The bulb's *wrapper* never breaks out — only
2881
+ its iframe does (see `.bulb-embed.spread > iframe` below), so the wrapper and the controls
2882
+ anchored to it stay put in the prose column; the geometry here is the shared lane the iframe
2883
+ reuses. Same lane width, same off-centre nudge (the messages gutter is 1rem wider on the left
2884
+ for the turn stripe, so a plain centre would sit right-of-centre). position + z-index:0 lift
2885
+ the box above the turn stripe (.bubble::before) the breakout crosses — it's later in tree order
2886
+ — but below later-in-tree pills; the mermaid SVG is transparent so a bg backs it, the bulb's
2887
+ iframe is opaque so it occludes the stripe itself. */
2888
+ .md .mermaid {
2889
+ width: calc(100vw - 2rem);
2890
+ margin: .6rem 0;
2891
+ margin-left: calc((100% - (100vw - 2rem)) / 2 - .5rem);
2892
+ margin-right: auto;
2893
+ position: relative;
2894
+ z-index: 0;
2895
+ background: var(--bg);
2896
+ }
2897
+
2898
+ /* Diagram specifics: scroll when wider than the lane; `safe center` centres one that fits and
2899
+ left-aligns one that overflows, so a wide flowchart scrolls from its start node. The SVG is
2900
+ transparent, so the turn stripe the breakout crosses shows through it — a bg box-shadow backs
2901
+ the diagram to occlude it (and clears the h-scrollbar edge). The bulb embed is opaque (its
2902
+ iframe fills the box), so it occludes the stripe itself and gets NO shadow — a shadow there
2903
+ would paint its 1rem ring over the half-line of prose just above the embed (the gap is .6rem). */
2904
+ .md .mermaid {
2905
+ overflow-x: auto;
2906
+ display: flex;
2907
+ justify-content: safe center;
2908
+ box-shadow: 0 0 0 1rem var(--bg);
2909
+ }
2910
+ /* flex-shrink:0: otherwise the SVG shrinks to the flex container and its viewBox
2911
+ scales it down — no overflow, no scrollbar. Natural width is what scrolls. */
2912
+ .md .mermaid svg { max-width: none; height: auto; flex: 0 0 auto; }
2913
+
2914
+ /* Raw ```svg``` embeds: no breakout — these are drawings (smiley, plot), not wide
2915
+ diagrams, so keep them in the prose column, centred and capped at its width. */
2916
+ .md .svg-embed { margin: .6rem 0; display: flex; justify-content: center; }
2917
+ .md .svg-embed svg { max-width: 100%; height: auto; }
2918
+
2919
+ /* ````bulb```` embeds: a sandboxed nested app. createBulbFrame owns the iframe (auto-height,
2920
+ borderless). No frame of our own — like the mermaid/svg embeds, it sits in the flow rather
2921
+ than in a box, so it reads as part of the transcript; the rounded clip just tidies the corners
2922
+ of a bulb that paints its own background. `inline` (default) keeps it in the prose column and
2923
+ caps its height (below); `spread` adds full-lane breakout + spacing from the shared rule above.
2924
+ position:relative anchors the controls overlay in BOTH modes (spread re-declares it for the
2925
+ z-index lift). .err is the compile-failure fallback; .bulb-err-strip is a runtime-error strip
2926
+ under a live embed (both monospace, muted red). */
2927
+ .md .bulb-embed {
2928
+ display: flex;
2929
+ flex-direction: column;
2930
+ position: relative;
2931
+ z-index: 0;
2932
+ /* Always cut the turn stripe, both modes: an opaque bg over position:relative + z-index:0
2933
+ paints above .bubble::before, and a -1rem left extension reaches the stripe in the gutter
2934
+ without a full breakout (padding-left:1rem keeps the content in the prose column; the box's
2935
+ right edge stays at prose-right, so the controls anchored there don't move). In spread mode the
2936
+ iframe's own breakout covers the stripe too. */
2937
+ background: var(--bg);
2938
+ margin-left: -1rem;
2939
+ padding-left: 1rem;
2940
+ /* Symmetric vertical gap as opaque padding, not margin: it occludes the stripe top and bottom
2941
+ so it's cut flush with the prose (no stub in a transparent gap), and is the same amount above
2942
+ and below to separate the bulb from surrounding text. margin top/bottom are zeroed — and the
2943
+ abutting prose margins too (below) — so the gap butts against the text. */
2944
+ margin-top: 0;
2945
+ margin-bottom: 0;
2946
+ padding-top: 1.1rem;
2947
+ padding-bottom: 1.1rem;
2948
+ }
2949
+ /* Inline (default): cap the embed's height so a tall bulb doesn't run away down the transcript.
2950
+ Past the cap the embed scrolls internally (its own overflow, set by the host↔embed protocol —
2951
+ the iframe element can't scroll its srcdoc from out here). spread removes the cap. */
2952
+ .md .bulb-embed.inline iframe { max-height: 80dvh; }
2953
+ /* The frame host is a layout-less wrapper (domeleon owns the node; we parent the live iframe into
2954
+ it once) — display:contents lifts the iframe to be a flex child of .bulb-embed, so the sizing and
2955
+ spread-breakout rules target it directly, exactly as when it was appended as a direct child. The
2956
+ code toggle hides it via .code-open rather than unmounting, so the bulb keeps its state. */
2957
+ .bulb-frame { display: contents; }
2958
+ .md .bulb-embed.code-open .bulb-frame { display: none; }
2959
+ /* Spread: the inner surface — the live iframe OR the code view behind the toggle, whichever is
2960
+ shown — breaks out to the full lane, while the wrapper stays in the prose column (so the
2961
+ controls anchored to it hold at prose-right, not the lane's hard-right edge). Spread is an
2962
+ embed-level state, so both surfaces honour it. Same lane geometry as `.mermaid` above; 100%
2963
+ here resolves against the prose-width wrapper. `width !important` beats createBulbFrame's
2964
+ inline `width:100%` on the iframe (harmless on the code view, which has no inline width); both
2965
+ are opaque, so position/z-index:0 lift them over the turn stripe they now cross. */
2966
+ .md .bulb-embed.spread iframe,
2967
+ .md .bulb-embed.spread > .bulb-code {
2968
+ width: calc(100vw - 2rem) !important;
2969
+ margin-left: calc((100% - (100vw - 2rem)) / 2 - .5rem);
2970
+ margin-right: auto;
2971
+ position: relative;
2972
+ z-index: 0;
2973
+ }
2974
+ /* Kill the transparent margin between the bulb and the prose it abuts (top and bottom):
2975
+ the bulb's own opaque padding is the gap, and a leftover prose margin would let the
2976
+ turn stripe show through it as a stub past the last/next line of text. */
2977
+ .md :has(+ .bulb-embed) { margin-bottom: 0; }
2978
+ .md .bulb-embed + * { margin-top: 0; }
2979
+ /* The rounded clip lives on the inner surfaces, not the embed (whose padding is now the
2980
+ gap), so the bulb's own card — the iframe, or the code view behind the toggle — is
2981
+ what carries the corners. */
2982
+ .md .bulb-embed iframe,
2983
+ .md .bulb-embed > .bulb-code {
2984
+ border-radius: 8px;
2985
+ }
2986
+ /* Overlay pill: a small hover-revealed control floated at a container's
2987
+ bottom-right, shared by the embed breakout button and the message copy button.
2988
+ Muted at rest, accent on hover (instant, matching the status pills). The
2989
+ container supplies the reveal; per-button bits layer on top. */
2990
+ .overlay-pill {
2991
+ position: absolute;
2992
+ bottom: .5rem;
2993
+ right: .5rem;
2994
+ font: inherit;
2995
+ font-size: .72rem;
2996
+ background: var(--panel);
2997
+ border: 1px solid var(--border);
2998
+ border-radius: 6px;
2999
+ color: var(--muted);
3000
+ padding: .12rem .45rem;
3001
+ cursor: pointer;
3002
+ opacity: 0;
3003
+ transition: opacity .2s ease;
3004
+ }
3005
+ .overlay-pill:hover { color: var(--accent); border-color: var(--accent); }
3006
+
3007
+ /* Controls (code ⇄ run, then copy, then breakout) — a centered overlay straddling the
3008
+ bulb's top edge: clear of the running bulb below, free to overlap the prose above.
3009
+ Absolute, so toggling run↔code (which resizes the bulb below) never moves it, and so
3010
+ the bulb's vertical gap stays small. Revealed on embed-hover or focus; inside the row
3011
+ the shared .overlay-pill drops its own corner-anchoring. */
3012
+ .md .bulb-controls {
3013
+ position: absolute;
3014
+ top: 0;
3015
+ right: .5rem;
3016
+ z-index: 2;
3017
+ display: flex;
3018
+ gap: .4rem;
3019
+ transform: translateY(-50%);
3020
+ }
3021
+ .md .bulb-controls .overlay-pill { position: static; }
3022
+ .md .bulb-embed:hover .bulb-controls .overlay-pill,
3023
+ .md .bulb-controls .overlay-pill:focus-visible { opacity: 1; }
3024
+ .md .bulb-breakout:disabled { cursor: default; opacity: 1; }
3025
+ /* Breakout in flight: shimmer through the ~2s write+spawn wait so the click reads as "working"
3026
+ rather than dead — the same treatment (and shared keyframes) as the launcher's play button
3027
+ (.bulb-launch.launching). :disabled already keeps it visible if the pointer leaves. */
3028
+ .md .bulb-breakout.launching {
3029
+ color: var(--accent); border-color: var(--accent);
3030
+ background: linear-gradient(100deg, transparent 30%, color-mix(in srgb, var(--accent) 35%, transparent) 50%, transparent 70%);
3031
+ background-size: 250% 100%;
3032
+ animation: bulb-shimmer 1.1s linear infinite;
3033
+ }
3034
+ @media (prefers-reduced-motion: reduce) {
3035
+ .md .bulb-breakout.launching { animation: none; background: color-mix(in srgb, var(--accent) 18%, transparent); }
3036
+ }
3037
+ /* Copy pill "copied" flash — mirrors the message .copy.done accent cue, scoped to the
3038
+ embed control so it doesn't pick up the bubble-hover reveal. */
3039
+ .md .bulb-copy.done { color: var(--accent); border-color: var(--accent); }
3040
+ /* Source view behind the toggle — the bulb's .bulb.md rendered as markdown (file
3041
+ labels + fenced code panels via the shared .md rules). Just a scroll container
3042
+ here, capped so a long bulb doesn't blow out the transcript. No `display` — the
3043
+ [hidden] attribute the toggle flips drives visibility. */
3044
+ /* Code view: a streamlined source listing — one labeled bar per file with the code
3045
+ flush beneath, not a stack of bordered panels. The container is just the scroll box;
3046
+ the file-label bar is the only fill (overriding .md pre's panel chrome below). */
3047
+ .md .bulb-code {
3048
+ margin: 0;
3049
+ padding: 0;
3050
+ max-height: 480px;
3051
+ overflow: auto;
3052
+ font-size: .85rem;
3053
+ /* A distinct surface from the transcript: the live bulb blends into the flow, but the
3054
+ code view reads as an inspector panel. Code panels (.md pre below) are transparent, so
3055
+ they show this through. */
3056
+ background: var(--tool-bg);
3057
+ }
3058
+ /* **file.ext** labels render as <p><strong>…</strong></p>; a bulb's source has no other
3059
+ top-level prose, so styling the paragraph as a header bar only ever hits a label. */
3060
+ .md .bulb-code p {
3061
+ margin: 0;
3062
+ padding: .3rem .85rem;
3063
+ /* A touch darker than the code surface so the file-label header stays distinct now
3064
+ that the whole code view shares one background. */
3065
+ background: color-mix(in srgb, var(--fg) 6%, var(--tool-bg));
3066
+ border-top: 1px solid var(--border);
3067
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
3068
+ font-size: .78rem;
3069
+ color: var(--muted);
3070
+ }
3071
+ .md .bulb-code p:first-child { border-top: none; }
3072
+ .md .bulb-code p strong { font-weight: 600; color: var(--fg); }
3073
+ /* Code flush on the embed bg — drop the inherited .md pre box (border/bg/radius). */
3074
+ .md .bulb-code pre {
3075
+ margin: 0;
3076
+ padding: .5rem .85rem .85rem;
3077
+ border: none;
3078
+ border-radius: 0;
3079
+ background: transparent;
3080
+ overflow-x: auto;
3081
+ }
3082
+ /* hljs token colors for the code view, mapped to the bulb's own theme vars (no hljs
3083
+ theme stylesheet) so highlighting follows light/dark rather than baking a palette
3084
+ that would clash in dark mode. Minimal token set — enough for legible TS/HTML/CSS/
3085
+ JSON/markdown without chasing every grammar scope. */
3086
+ .bulb-code .hljs-comment, .bulb-code .hljs-quote { color: var(--muted); font-style: italic; }
3087
+ .bulb-code .hljs-keyword, .bulb-code .hljs-literal, .bulb-code .hljs-built_in { color: var(--accent); }
3088
+ .bulb-code .hljs-tag, .bulb-code .hljs-name, .bulb-code .hljs-selector-tag { color: var(--accent); }
3089
+ .bulb-code .hljs-string, .bulb-code .hljs-regexp, .bulb-code .hljs-meta .hljs-string {
3090
+ color: color-mix(in srgb, var(--diff-add) 85%, var(--fg));
3091
+ }
3092
+ .bulb-code .hljs-number { color: color-mix(in srgb, var(--accent) 55%, var(--fg)); }
3093
+ .bulb-code .hljs-attr, .bulb-code .hljs-attribute, .bulb-code .hljs-property, .bulb-code .hljs-type {
3094
+ color: color-mix(in srgb, var(--accent) 75%, var(--fg));
3095
+ }
3096
+ .bulb-code .hljs-title, .bulb-code .hljs-title.function_, .bulb-code .hljs-section {
3097
+ color: var(--fg); font-weight: 600;
3098
+ }
3099
+ .bulb-code .hljs-symbol, .bulb-code .hljs-bullet, .bulb-code .hljs-punctuation { color: var(--muted); }
3100
+ .md .bulb-embed.err {
3101
+ padding: .6rem .8rem;
3102
+ font-family: ui-monospace, monospace;
3103
+ font-size: .85em;
3104
+ color: color-mix(in srgb, #e23 70%, var(--fg));
3105
+ white-space: pre-wrap;
3106
+ }
3107
+ .md .bulb-err-strip {
3108
+ padding: .45rem .7rem;
3109
+ border-top: 1px solid var(--border);
3110
+ background: color-mix(in srgb, #e23 8%, var(--bg));
3111
+ font-family: ui-monospace, monospace;
3112
+ font-size: .8em;
3113
+ color: color-mix(in srgb, #e23 75%, var(--fg));
3114
+ white-space: pre-wrap;
3115
+ }
3116
+
3117
+ /* tool calls */
3118
+ /* Closed tool: just a single-line row, no chrome — a tool-only turn stays a
3119
+ tight list, not N stacked panels. Open tools get the panel treatment below. */
3120
+ .tool {
3121
+ font-size: .85rem;
3122
+ }
3123
+ /* Clickable caret row, shared by tool heads and the collapsed-turn summary: a flex
3124
+ row that's an unselectable click target. align-items:center centers the caret
3125
+ against the label box (verb↔summary alignment is handled inside .tool-label). */
3126
+ .tool-head, .turn-summary {
3127
+ display: flex;
3128
+ align-items: center;
3129
+ gap: .5rem;
3130
+ padding: .15rem 0;
3131
+ cursor: pointer;
3132
+ user-select: none;
3133
+ }
3134
+ .tool.open {
3135
+ margin-top: .55rem;
3136
+ background: var(--tool-bg);
3137
+ border: 1px solid var(--border);
3138
+ border-radius: 8px;
3139
+ }
3140
+ .tool.open .tool-head {
3141
+ padding: .4rem .6rem;
3142
+ }
3143
+ /* line-height: 1 keeps the oversized caret's box from inflating the row height. */
3144
+ .tool-caret { color: var(--muted); font-size: 1.4rem; line-height: 1; width: 1ch; }
3145
+ .tool-name { font-weight: 600; color: var(--accent); }
3146
+ /* Errors here are routine (a first-read miss the retry fixes), so a failed tool
3147
+ recolors just its verb rather than boxing the whole row — a quiet signal. */
3148
+ .tool.err .tool-name { color: var(--err); }
3149
+
3150
+ /* Collapsed-turn summary strip (intermediate steps hidden behind the final
3151
+ answer). Same caret/row shape as a tool head, italic muted label. */
3152
+ .turn-summary { color: var(--muted); font-size: .85rem; }
3153
+ .turn-summary-text { font-style: italic; }
3154
+ .turn-summary:hover .turn-summary-text { color: var(--fg); }
3155
+ /* Verb + summary are inline siblings sharing one line box and font, so they sit
3156
+ on a common baseline. Ellipsis truncation lives on the wrapper, not the
3157
+ children — overflow:hidden on the summary itself skews its baseline. */
3158
+ .tool-label {
3159
+ flex: 1;
3160
+ min-width: 0;
3161
+ white-space: nowrap;
3162
+ overflow: hidden;
3163
+ text-overflow: ellipsis;
3164
+ }
3165
+ .tool-sum {
3166
+ margin-left: .5rem;
3167
+ color: var(--muted);
3168
+ /* Re-enable selection the .tool-head blankets off: the verb/caret stay an
3169
+ unselectable click target, but the summary is real data (path, pattern,
3170
+ command) worth copying. A drag selects; a plain click still toggles. */
3171
+ user-select: text;
3172
+ }
3173
+ .tool-run { color: var(--muted); }
3174
+
3175
+ /* Code-display panels inside an open tool card: raw input/output (.tool-in,
3176
+ .tool-out) and per-hunk diff halves (.diff-old, .diff-new). All four share
3177
+ the same monospace block geometry — padding, max-height with scroll,
3178
+ pre-wrap with word-break, top-divider — so consolidating here means edits
3179
+ to "how a code panel looks" land in one place. Color treatment is the only
3180
+ per-variant difference. */
3181
+ .tool-in, .tool-out, .diff-old, .diff-new {
3182
+ margin: 0;
3183
+ padding: .55rem .7rem;
3184
+ border-top: 1px solid var(--border);
3185
+ white-space: pre-wrap;
3186
+ word-break: break-word;
3187
+ font-family: ui-monospace, Menlo, monospace;
3188
+ font-size: .8rem;
3189
+ max-height: 320px;
3190
+ overflow: auto;
3191
+ }
3192
+ .tool-out { color: var(--muted); }
3193
+ .diff-old {
3194
+ background: color-mix(in srgb, var(--err) 14%, transparent);
3195
+ color: var(--err);
3196
+ }
3197
+ .diff-new {
3198
+ background: color-mix(in srgb, var(--diff-add) 16%, transparent);
3199
+ color: var(--diff-add);
3200
+ }
3201
+
3202
+ /* Path link: plain accent, underline only on hover. skip-ink:none so the
3203
+ underline stays solid under a path's descenders and slashes. */
3204
+ .tool-sum.link {
3205
+ color: var(--accent);
3206
+ text-decoration: none;
3207
+ }
3208
+ .tool-sum.link:hover { text-decoration: underline; text-decoration-skip-ink: none; }
3209
+
3210
+ .diff { display: flex; flex-direction: column; }
3211
+ .diff-step {
3212
+ padding: .35rem .7rem;
3213
+ border-top: 1px solid var(--border);
3214
+ color: var(--muted);
3215
+ font-size: .72rem;
3216
+ text-transform: uppercase;
3217
+ letter-spacing: .06em;
3218
+ }
3219
+
3220
+ .thinking { margin-bottom: .4rem; font-size: .82rem; color: var(--muted); }
3221
+ .thinking pre { white-space: pre-wrap; }
3222
+
3223
+ /* Copy button: an .overlay-pill revealed on bubble-hover. */
3224
+ .bubble:hover .copy { opacity: 1; }
3225
+ /* "copied" flash: eases into accent on click (CopyButton is a Component, so its
3226
+ node persists across re-renders and the transition actually runs). The color
3227
+ transition lives only on .done, not the base pill, so hover stays instant — the
3228
+ trade is the revert snaps back rather than easing out. */
3229
+ .copy.done {
3230
+ opacity: 1;
3231
+ color: var(--accent);
3232
+ border-color: var(--accent);
3233
+ transition: opacity .2s ease, color .5s ease, border-color .5s ease;
3234
+ }
3235
+ /* Assistant bubbles carry no padding, so the shared .5rem bottom would float the
3236
+ pill above the last line — pin it to the text's bottom edge. */
3237
+ .bubble.assistant .copy { bottom: 0; }
3238
+ ```
3239
+
3240
+ **index.html**
3241
+
3242
+ ```html
3243
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css">
3244
+ <div id="app"></div>
3245
+ ```
3246
+
3247
+ **config.json**
3248
+
3249
+ ```json
3250
+ {
3251
+ "description": "Read-only browser mirror of your project's Claude Code session: rendered markdown and math, collapsible tools and diffs, a session picker and token counter. Tails the on-disk transcript and drives nothing.",
3252
+ "dependencies": {
3253
+ "domeleon": "^0.6.0",
3254
+ "markdown-it": "^14.1.0",
3255
+ "katex": "^0.16.22",
3256
+ "beautiful-mermaid": "^1.1.3",
3257
+ "dompurify": "^3.2.6",
3258
+ "highlight.js": "^11.10.0",
3259
+ "typebulb": "^0.9.7"
3260
+ }
3261
+ }
3262
+ ```