typebulb 0.9.4 → 0.9.6

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