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