typebulb 0.10.7 → 0.11.0

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