Every Claude Code session on your machine in one pane, and which one needs you.

<img src="../../brand/pixel-icons/svg/switchboard.svg" width="64" height="64" alt="">
Every Claude Code session on your machine in one pane, and which one needs you.
2 need you
You are here: api-server (terminal)
! Fix the login desktop
waiting on a permission for Bash · 2m
✕ tests stopped on an error 5m ago terminal
● docs working for 2m terminal ≈$0.49
○ web idle for 12m terminal
○ 26 quiet sessions: idle, no turn in the last hour [Show]
- old closed 2m ago terminal
/plugin install switchboard --marketplace modmyclaude/claude-code-mods
Install it in every session you run (a user-scope install does that): each session shows up once Switchboard runs in it.
/switchboard opens the pane. The first line says how many sessions need you, and the next one says which session you are in and where.! waiting on a permission (and for which tool), a subagent's permission, a question, a plan to approve, or an answer✕ stopped on an error● working○ idle (or new, opened and not prompted yet)- closed (it stopped reporting; listed for an hour)! Fix the login needs you (desktop), with Show to open the pane), and the footer counts them (1 needs you). Never for this session's own dialog, which is already in front of you.Fix the login needs you (desktop).It only watches. It never switches to, answers or types into another session: go to that session to answer it.
/switchboard says it is off. /config or the modmyclaude hub flips it, live.~/.claude/switchboard/<session id>.json (or under CLAUDE_CONFIG_DIR when that is set), rewritten when its state changes and every 30 seconds, and once more, as closed, when you switch Switchboard off. It holds the session's name, state, where it runs, what a dialog waits on (a tool's name, such as Bash), three times (when its state began, when it last had a turn, and when the file was written), whether it is new (not prompted yet), and its cost figure. The name is the session's title, which Claude Code writes as a short summary of your first prompt (or you set in the desktop sidebar), else the folder's name. Nothing else of your prompts, Claude's replies or your files.tail on this session's own transcript (its last 256 KB) to find the session's title: when it starts, and at a turn's end at most every two minutes. Until the session has a title it also looks at every turn's end, at Claude Code's "waiting on you" notice, at a /clear or resume, and at the first prompt, so a new session gets its name quickly. Only the title lines are kept; the rest is never stored or shown.tail (Windows), the title comes only with your next prompt.This mod was built from the Switchboard prompt.
More mods: the full catalogue.
hooks/register.tsx 607 lines1// Switchboard: every Claude Code session on this machine in one pane, and which
2// one needs you. Each session running Switchboard writes a small record about
3// itself (its title or folder, its state, where it runs, what a dialog waits
4// on, times and cost; never message text) to ~/.claude/switchboard/<id>.json,
5// from its own events: turns starting and ending, the tool a turn calls, and
6// Claude Code's own "a dialog is waiting on you" notice. A session someone is
7// looking at reads the others every few seconds; a record that stops being
8// rewritten is a session that is gone.
9//
10// Watch only: it never focuses, answers or types into another session.
11
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, FsEntry, Register, RenderNode, Timer } from 'claude-code'
14
15import type { SwitchboardRow, SwitchboardWhere } from '../types'
16import {
17 EMPTY_LINE, HEARTBEAT_MS, MARK, MAX_SESSIONS, POLL_MS, SHOWN_GONE_MS, STALE_MS, WHERE_WORDS, bandLine, costWords, drawnKey,
18 folderName, footerLabel, hereLine, heroLine, newlyNeeding, oneLine, parseRecord, quietLine, rowsOf, seenOf, stateWords,
19 titleFromTail, toastLine, transcriptPath, waitingWords,
20} from './board'
21import type { SessionRecord } from './board'
22import { BOLT_COLUMNS, boltSprite } from './bolt-draw'
23
24const PANE = 'switchboard'
25const rows = atom({ plugin: 'switchboard', key: 'rows' } as const, [])
26const readAt = atom({ plugin: 'switchboard', key: 'readAt' } as const, 0)
27const showQuiet = atom({ plugin: 'switchboard', key: 'showQuiet' } as const, false)
28
29/** Claude Code's own notifications that a dialog is waiting on the person (as pager reads them). */
30const WAITING = new Set(['permission_prompt', 'worker_permission_prompt', 'elicitation_dialog', 'elicitation_url_dialog', 'agent_needs_input'])
31/** How much of the end of its own transcript a session reads for its title. */
32const TITLE_TAIL_BYTES = 262_144
33/** The title is looked up again at most this often (at a turn's end). */
34const TITLE_EVERY_MS = 2 * 60_000
35
36/** This session as it reports itself. */
37type Me = {
38 id: string
39 folder: string
40 cwd: string
41 title?: string
42 state: SessionRecord['state']
43 since: number
44 where: SwitchboardWhere
45 waitingOn?: string
46 lastActiveAt?: number
47 /** Opened and not prompted yet. */
48 isNew: boolean
49 costUsd?: number
50 writtenAt: number
51 /** The tool the main loop last called: what a permission dialog is about. */
52 lastTool?: string
53 transcript?: string
54 titleCheckedAt: number
55}
56
57// This copy of the module: this session's own record, where records live, the
58// timer, and what the last read saw (so each toast fires once per change).
59let me: Me | null = null
60let base: string | undefined
61let timer: Timer | null = null
62let seen: Map<string, string> | undefined
63let lastKey = ''
64let isReading = false
65/** Each file's last parsed record by its mtime: a file that did not change is not read again. */
66const cache = new Map<string, { mtimeMs: number; record: SessionRecord }>()
67/** The id a /clear or resume just ended: nothing is written under it again. */
68let endedId: string | null = null
69/**
70 * The transcript Claude Code names on its settings-hook events. It is keyed by the folder the
71 * session started in, which the working directory can leave (a cd, a worktree), so it beats the
72 * path worked out from the folder; kept even before this copy's record exists.
73 */
74let transcriptHint: string | undefined
75
76// The switch (`enabled`, which /config and the modmyclaude hub flip): off, Switchboard writes
77// no record, reads no other session's, keeps no timer and draws nothing. The engine reloads
78// the mod with the new value; the config.set hook below is the same switch at once.
79let isOn = true
80const OFF_TEXT = 'Switchboard is off. /mods or /config turns it on.'
81
82async function safely($: EngineInterface, work: () => Promise<unknown>): Promise<void> {
83 try {
84 await work()
85 } catch (error) {
86 $.ui.log(`switchboard: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
87 }
88}
89
90const folderOf = () => (base === undefined ? undefined : `${base}/switchboard`)
91
92/** ~/.claude, or CLAUDE_CONFIG_DIR, as Claude Code finds its own folder. */
93async function configDir($: EngineInterface): Promise<string | undefined> {
94 const config = await $.env.get('CLAUDE_CONFIG_DIR')
95 if (config) return config.replace(/[\\/]+$/, '')
96 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
97 return home ? `${home.replace(/[\\/]+$/, '')}/.claude` : undefined
98}
99
100/** Where the session can be found: the desktop app, a terminal, or nothing attached. */
101async function whereNow($: EngineInterface): Promise<SwitchboardWhere> {
102 const surfaces = await $.session.surfaces()
103 return surfaces.includes('desktop') ? 'desktop' : surfaces.includes('terminal') ? 'terminal' : 'background'
104}
105
106function recordOf(mine: Me, now: number): SessionRecord {
107 return {
108 v: 1,
109 id: mine.id,
110 name: oneLine(mine.title ?? mine.folder, 48) || 'session',
111 state: mine.state,
112 since: mine.since,
113 updatedAt: now,
114 where: mine.where,
115 ...(mine.state === 'needs-you' && mine.waitingOn !== undefined ? { waitingOn: mine.waitingOn } : {}),
116 ...(mine.lastActiveAt === undefined ? {} : { lastActiveAt: mine.lastActiveAt }),
117 ...(mine.isNew ? { isNew: true } : {}),
118 ...(mine.costUsd === undefined ? {} : { costUsd: mine.costUsd }),
119 }
120}
121
122/** Rewrites this session's record: on every change, and as a heartbeat. */
123async function writeMine($: EngineInterface): Promise<void> {
124 const dir = folderOf()
125 if (me === null || dir === undefined) return
126 if (me.id === endedId) {
127 // Between a /clear and its new id: nothing is written under the ended one; the new one
128 // is taken as soon as Claude Code answers it (until then the id it answers is the same).
129 return becomeSession($, await $.session.id())
130 }
131 const now = await $.clock.now()
132 me.where = await whereNow($)
133 // Switched off meanwhile: its closed record stands.
134 if (!isOn) return
135 await $.fs.write(`${dir}/${me.id}.json`, JSON.stringify(recordOf(me, now)))
136 me.writtenAt = now
137}
138
139async function setState($: EngineInterface, state: SessionRecord['state'], waitingOn?: string): Promise<void> {
140 if (me === null || (me.state === state && me.waitingOn === waitingOn)) return
141 const now = await $.clock.now()
142 if (me.state !== state) me.since = now
143 me.state = state
144 me.waitingOn = waitingOn
145 if (state === 'working' || state === 'needs-you') {
146 me.lastActiveAt = now
147 me.isNew = false
148 }
149 await writeMine($)
150 await readOthers($)
151}
152
153/**
154 * This session's title from the end of its own transcript (the sidebar's, else
155 * Claude Code's own), at most every two minutes. Only the title lines are read;
156 * a host without `tail`, or a transcript not there yet, leaves the name as it is.
157 */
158async function refreshTitle($: EngineInterface, isDue = false): Promise<void> {
159 if (me === null || base === undefined) return
160 const now = await $.clock.now()
161 if (!isDue && now - me.titleCheckedAt < TITLE_EVERY_MS) return
162 me.titleCheckedAt = now
163 const path = me.transcript ?? transcriptPath(base, me.cwd, me.id)
164 try {
165 const { exitCode, stdout } = await $.process.run(['tail', '-c', String(TITLE_TAIL_BYTES), path], { timeoutMs: 5_000 })
166 const title = exitCode === 0 ? titleFromTail(stdout) : undefined
167 if (title !== undefined) await setTitle($, title)
168 } catch {
169 // No tail on this host: the title comes from the prompt events instead.
170 }
171}
172
173/** Reads every recent record (this session's from memory), raises a toast per new "needs you", and redraws only on a change. */
174async function readOthers($: EngineInterface): Promise<void> {
175 const dir = folderOf()
176 if (me === null || dir === undefined || isReading) return
177 isReading = true
178 try {
179 const now = await $.clock.now()
180 let entries: FsEntry[] = []
181 try {
182 entries = await $.fs.list(dir)
183 } catch {
184 entries = [] // no folder yet: nobody else has written
185 }
186 const recent = entries
187 .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json') && now - entry.mtimeMs <= SHOWN_GONE_MS + STALE_MS)
188 .sort((a, b) => b.mtimeMs - a.mtimeMs)
189 .slice(0, MAX_SESSIONS)
190 const records: SessionRecord[] = [recordOf(me, now)]
191 for (const entry of recent) {
192 if (entry.name === `${me.id}.json`) continue
193 const hit = cache.get(entry.name)
194 if (hit !== undefined && hit.mtimeMs === entry.mtimeMs) {
195 records.push(hit.record)
196 continue
197 }
198 try {
199 const record = parseRecord(await $.fs.read(`${dir}/${entry.name}`))
200 // A half-written or unknown file is not kept: the next read tries it again.
201 if (record !== null) {
202 cache.set(entry.name, { mtimeMs: entry.mtimeMs, record })
203 records.push(record)
204 }
205 } catch {
206 // Gone between the list and the read: the next read has it.
207 }
208 }
209 const listed = new Set(recent.map(entry => entry.name))
210 for (const name of cache.keys()) if (!listed.has(name)) cache.delete(name)
211 // Switched off meanwhile: nothing of this read is kept, so on again is a fresh look.
212 if (!isOn) return
213 const next = rowsOf(records, me.id, now)
214 for (const row of newlyNeeding(next, seen)) $.ui.toast(toastLine(row))
215 seen = seenOf(next)
216 const key = drawnKey(next, now)
217 if (key !== lastKey) {
218 lastKey = key
219 await update($, rows, () => next)
220 await update($, readAt, () => now)
221 }
222 } finally {
223 isReading = false
224 }
225}
226
227/** Every few seconds: the heartbeat when it is due, and a read while someone is looking. */
228async function tick($: EngineInterface): Promise<void> {
229 if (me === null) return
230 const now = await $.clock.now()
231 if (now - me.writtenAt >= HEARTBEAT_MS) await writeMine($)
232 if ((await $.session.surfaces()).length > 0) await readOthers($)
233}
234
235/** This session under a new id (a /clear or a resume): it starts idle, with no title and none of the old conversation's turns. */
236async function becomeSession($: EngineInterface, id: string): Promise<void> {
237 if (me === null || me.id === id) return
238 const isNew = (await $.session.turns().catch(() => 1)) === 0
239 me = { ...me, id, title: undefined, state: 'idle', since: await $.clock.now(), waitingOn: undefined, isNew, lastActiveAt: undefined, writtenAt: 0, transcript: undefined, titleCheckedAt: 0 }
240 await writeMine($)
241 await readOthers($)
242}
243
244async function setTitle($: EngineInterface, title: string | undefined): Promise<void> {
245 const clean = title?.trim() ? oneLine(title, 48) : undefined
246 if (me === null || clean === undefined || me.title === clean) return
247 me.title = clean
248 await writeMine($)
249 await readOthers($)
250}
251
252/** Keeps the transcript path a settings-hook event named; true when it is news. */
253function noteTranscript(path: string | undefined): boolean {
254 if (!path || path === transcriptHint) return false
255 transcriptHint = path
256 if (me !== null) me.transcript = path
257 return true
258}
259
260/** Starts reporting: this session's record, the timer and a first read (at a start, or switched on). */
261async function begin($: EngineInterface): Promise<void> {
262 base = await configDir($)
263 const cwd = await $.session.root()
264 const now = await $.clock.now()
265 // Folded again on every start: the quiet list opens only when asked, in this session.
266 await update($, showQuiet, () => false)
267 const isNew = (await $.session.turns().catch(() => 1)) === 0
268 me = { id: await $.session.id(), folder: folderName(cwd), cwd, state: 'idle', since: now, where: await whereNow($), isNew, writtenAt: 0, transcript: transcriptHint, titleCheckedAt: 0 }
269 await refreshTitle($, true)
270 await writeMine($)
271 timer ??= $.clock.every(POLL_MS, () => void safely($, () => tick($)))
272 await readOthers($)
273}
274
275/**
276 * Switched off: this session stops reporting and reading. Its record is written once more, as
277 * closed, so other sessions stop showing it working or waiting on you now rather than when it
278 * goes stale; switched on, a fresh record replaces it. Only this session's own file is touched.
279 */
280async function signOff($: EngineInterface): Promise<void> {
281 timer?.cancel()
282 timer = null
283 // On again is a fresh look: what changed meanwhile shows in the band, without a toast each.
284 seen = undefined
285 // Its events go unheard while off: after a /clear then, this path would be the old conversation's.
286 transcriptHint = undefined
287 const dir = folderOf()
288 // Already closed, or the record of a conversation a /clear or resume ended: that one says gone.
289 if (me === null || dir === undefined || me.state === 'gone' || me.id === endedId) return
290 const now = await $.clock.now()
291 await $.fs.write(`${dir}/${me.id}.json`, JSON.stringify({ ...recordOf(me, now), state: 'gone', since: now }))
292}
293
294/**
295 * Opens the pane. One pane per id: an id Claude Code still holds open only retitles, so a pane the
296 * desktop hid (closed or squeezed away without a close reaching the engine) would stay hidden.
297 * A held but unshown one is closed first, so this open draws it fresh.
298 */
299async function openPane($: EngineInterface) {
300 try {
301 const held = (await $.ui.panes()).find(pane => pane.id === PANE)
302 if (held !== undefined && !held.isShown) await $.ui.close({ id: PANE })
303 } catch {
304 // No pane list on this host: open as asked.
305 }
306 return $.ui.open({ id: PANE, title: 'Switchboard', focus: true, closeOnEscape: true })
307}
308
309export const register: Register = (on, options) => {
310 isOn = options.enabled !== false
311
312 on('config.set', { key: 'switchboard.enabled' }, async ($, e, next) => {
313 const set = await next(e)
314 if (set.deny === undefined && typeof set.value === 'boolean') {
315 const wasOn = isOn
316 isOn = set.value
317 // Only a change: on again would start this session's record over.
318 if (isOn !== wasOn) await safely($, () => (isOn ? begin($) : signOff($)))
319 $.ui.invalidate('ui.render')
320 }
321 return set
322 })
323
324 on('session.start', async ($, e, next) => {
325 await $.command.register({
326 name: 'switchboard',
327 description: 'Every Claude Code session on this machine, and which one needs you',
328 immediate: true,
329 })
330 const started = await next(e)
331 // Off, the command stays and nothing else starts: no record, no timer, no read.
332 if (isOn) await safely($, () => begin($))
333 return started
334 })
335
336 // The session's title, id and transcript ride on these settings-hook events; read them, change nothing.
337 on('classic.SessionStart', async ($, e, next) => {
338 if (!isOn) return next(e)
339 await safely($, async () => {
340 if (e.session_id) await becomeSession($, e.session_id)
341 noteTranscript(e.transcript_path)
342 if (e.session_title?.trim()) await setTitle($, e.session_title)
343 else await refreshTitle($, true)
344 })
345 return next(e)
346 })
347 on('classic.UserPromptSubmit', async ($, e, next) => {
348 if (!isOn) return next(e)
349 await safely($, async () => {
350 const isNewPath = noteTranscript(e.transcript_path)
351 if (e.session_title?.trim()) await setTitle($, e.session_title)
352 // The first sure path to an untitled session's transcript: look now, not two minutes on.
353 else if (isNewPath && me?.title === undefined) await refreshTitle($, true)
354 })
355 return next(e)
356 })
357
358 on('turn.start', async ($, e, next) => {
359 if (!isOn) return next(e)
360 await safely($, () => setState($, 'working'))
361 return next(e)
362 })
363
364 // A main turn's end: idle, or an error that wants the person; and the title, which Claude Code
365 // writes after the first prompt, is looked up again (at most every two minutes).
366 on('turn.complete', async ($, e, next) => {
367 if (isOn && e.agentId === undefined) {
368 await safely($, async () => {
369 await setState($, e.reason === 'error' || e.reason === 'refusal' ? 'error' : 'idle')
370 // An untitled session looks at every turn end until Claude Code has titled it.
371 await refreshTitle($, me?.title === undefined)
372 })
373 }
374 return next(e)
375 })
376
377 // Claude Code's own "a dialog is waiting on you": a permission (for the tool the turn just called), a question, a plan.
378 on('classic.Notification', async ($, e, next) => {
379 if (isOn && WAITING.has(e.notification_type)) {
380 await safely($, async () => {
381 await setState($, 'needs-you', waitingWords(e.notification_type, me?.lastTool))
382 // A first turn can ask before any turn has ended: look for the title now too.
383 await refreshTitle($, me?.title === undefined)
384 })
385 }
386 return next(e)
387 })
388
389 // The tool a dialog asks about; and once it ran, the dialog was answered and the session works again.
390 on('tool.call', async ($, e, next) => {
391 if (!isOn) return next(e)
392 if (me !== null && e.agentId === undefined) me.lastTool = String(e.tool)
393 const ran = await next(e)
394 if (me?.state === 'needs-you') await safely($, () => setState($, 'working'))
395 return ran
396 })
397
398 on('session.measure', ($, e, next) => {
399 if (isOn && me !== null && e.cost !== undefined) me.costUsd = e.cost.usd
400 return next(e)
401 })
402
403 // The conversation ends. On a /clear or a resume the process goes on under a new id
404 // (classic.SessionStart brings it); any other end is this session closing.
405 on('session.end', async ($, e, next) => {
406 if (!isOn) return next(e)
407 await safely($, async () => {
408 const dir = folderOf()
409 if (me === null || dir === undefined) return
410 const now = await $.clock.now()
411 // The ended conversation's record says gone, whichever id this copy has moved to.
412 const ended = e.sessionId || me.id
413 await $.fs.write(`${dir}/${ended}.json`, JSON.stringify({ ...recordOf(me, now), id: ended, state: 'gone', since: now }))
414 if (e.reason === 'clear' || e.reason === 'resume') {
415 endedId = ended
416 } else {
417 me.state = 'gone'
418 me.since = now
419 timer?.cancel()
420 timer = null
421 }
422 })
423 return next(e)
424 })
425
426 // A surface came to look (or left): say where this session is now, and read at once.
427 on('session.attach', async ($, e, next) => {
428 const attached = await next(e)
429 if (!isOn) return attached
430 await safely($, async () => {
431 await writeMine($)
432 await readOthers($)
433 })
434 return attached
435 })
436
437 on('command.run', { command: 'switchboard' }, async $ => {
438 if (!isOn) {
439 $.ui.toast(OFF_TEXT)
440 return {}
441 }
442 const opened = await openPane($)
443 if (!opened.isPlaced) {
444 const list = await read($, rows)
445 $.ui.toast(bandLine(list) ?? heroLine(list).text)
446 }
447 return {}
448 }).catch($ => {
449 $.ui.toast('/switchboard could not open.')
450 return {}
451 })
452
453 // The footer: a count beside the engine's own labels while another session needs you.
454 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
455 const engine = await next(e)
456 if (!isOn) return engine
457 const label = footerLabel(await read($, rows))
458 if (label === null) return engine
459 const { Box, Text } = $.ui.resolve(e)
460 return (
461 <Box flexDirection="row">
462 {engine}
463 {e.props.modes.length > 0 ? <Text dimColor> & </Text> : null}
464 <Box key="switchboard">
465 <Text color="warning">{label}</Text>
466 </Box>
467 </Box>
468 )
469 })
470
471 // The band: one line, beneath other mods' lines, only while another session needs you.
472 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
473 if (!isOn || e.props.hasSurvey) return next(e)
474 const line = bandLine(await read($, rows))
475 if (line === null) return next(e)
476 const rest = await next(e)
477 const { Box, Button, Text } = $.ui.resolve(e)
478 return (
479 <Box key="switchboard-wrap" flexDirection="column">
480 {rest}
481 <Box key="switchboard" flexDirection="row" gap={1}>
482 <Text color="warning" wrap="truncate-end">{`! ${line}`}</Text>
483 <Button key="switchboard-show" label="Show" onPress={() => openPane($)} />
484 </Box>
485 </Box>
486 )
487 })
488
489 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
490 const elements = $.ui.resolve(e)
491 const { Box, Button, Text } = elements
492 if (!isOn) {
493 return (
494 <Box flexDirection="column">
495 <Text key="off" dimColor>
496 {OFF_TEXT}
497 </Text>
498 </Box>
499 )
500 }
501 const list = await read($, rows)
502 const isQuietShown = await read($, showQuiet)
503 const now = (await read($, readAt)) || (await $.clock.now())
504 const columns = e.props.bodyColumns
505 const isWide = columns >= 72
506 const hero = heroLine(list)
507 const others = list.filter(row => !row.isMine)
508 const quiet = others.filter(row => row.isQuiet)
509 const gone = others.filter(row => row.state === 'gone')
510 const active = others.filter(row => !row.isQuiet && row.state !== 'gone')
511
512 // One row per session. The name gives way first, so what it is doing and where always show;
513 // a session waiting on you says what for on a line of its own, never cut by the pane's edge.
514 const rowOf = (row: SwitchboardRow, isDim = false) => {
515 const mark = MARK[row.state]
516 const isWaiting = row.state === 'needs-you'
517 const words = <Text color={mark.color === 'success' ? undefined : mark.color} dimColor={mark.dimColor}>{stateWords(row, now)}</Text>
518 const line = (
519 <Box key="line" flexDirection="row" gap={1}>
520 <Box key="mark" width={1} flexShrink={0}>
521 <Text color={mark.color} dimColor={mark.dimColor}>{mark.shape}</Text>
522 </Box>
523 <Box key="name" flexGrow={1} flexShrink={1} minWidth={8}>
524 <Text wrap="truncate-end" dimColor={isDim} bold={isWaiting}>{row.name}</Text>
525 </Box>
526 {isWaiting ? null : (
527 <Box key="state" flexShrink={0}>
528 {words}
529 </Box>
530 )}
531 {isWide ? (
532 <Box key="where" width={10} flexShrink={0} justifyContent="flex-end">
533 <Text color="subtle">{WHERE_WORDS[row.where]}</Text>
534 </Box>
535 ) : null}
536 {isWide ? (
537 <Box key="cost" width={8} flexShrink={0} justifyContent="flex-end">
538 <Text color="subtle">{costWords(row.costUsd)}</Text>
539 </Box>
540 ) : null}
541 </Box>
542 )
543 return (
544 <Box key={`row-${row.id}`} flexDirection="column">
545 {line}
546 {isWaiting ? (
547 <Box key="waiting" paddingLeft={2}>
548 <Text color="warning" wrap="wrap">{stateWords(row, now)}</Text>
549 </Box>
550 ) : null}
551 </Box>
552 )
553 }
554
555 const isRoomy = columns >= BOLT_COLUMNS.tiny + 30
556 const body: RenderNode[] = [
557 <Box key="hero" flexDirection="row" gap={1} alignItems="center">
558 {isRoomy ? boltSprite(elements, e.surface, { size: 'tiny', pose: hero.tone === 'warning' ? 'jump' : 'sleep' }, 'hero-bolt') : null}
559 <Box key="hero-text">
560 <Text bold color={hero.tone}>{hero.text}</Text>
561 </Box>
562 </Box>,
563 ]
564 const here = hereLine(list)
565 if (here !== null) {
566 body.push(
567 <Box key="here">
568 <Text color="subtle" wrap="truncate-end">{here}</Text>
569 </Box>,
570 )
571 }
572 if (others.length === 0) {
573 body.push(
574 <Box key="empty" marginTop={1}>
575 <Text dimColor wrap="wrap">{EMPTY_LINE}</Text>
576 </Box>,
577 )
578 } else {
579 body.push(<Box key="gap" height={1} />)
580 for (const row of active) body.push(rowOf(row))
581 if (quiet.length > 0) {
582 body.push(
583 <Box key="quiet" flexDirection="row" gap={1}>
584 <Box key="mark" width={1} flexShrink={0}>
585 <Text dimColor>{MARK.idle.shape}</Text>
586 </Box>
587 <Text dimColor wrap="truncate-end">{quietLine(quiet.length)}</Text>
588 <Button key="quiet-toggle" label={isQuietShown ? 'Hide' : 'Show'} onPress={() => update($, showQuiet, shown => !shown)} />
589 </Box>,
590 )
591 if (isQuietShown) for (const row of quiet) body.push(rowOf(row, true))
592 }
593 for (const row of gone) body.push(rowOf(row, true))
594 }
595 body.push(
596 <Box key="foot" marginTop={1}>
597 <Text color="subtle" wrap="wrap">It only watches: go to a session to answer it. Sessions show up once Switchboard runs in them.</Text>
598 </Box>,
599 )
600 return (
601 <Box key="switchboard-pane" flexDirection="column">
602 {body}
603 </Box>
604 )
605 })
606}
607hooks/board.ts 289 lines1// Switchboard's pure parts: the record a session writes about itself, how a
2// reader turns records into rows, and the words the pane, band, footer and
3// toasts say. Nothing here touches the engine, so the tests pin every case.
4
5import type { SwitchboardRow, SwitchboardState, SwitchboardWhere } from '../types'
6
7/**
8 * What a session writes about itself, one small JSON file per session: its name
9 * (its title, else its folder), state, where it runs, what a dialog waits on (in
10 * Claude Code's words: a tool's name), times and cost. Never message text.
11 */
12export type SessionRecord = {
13 v: 1
14 id: string
15 name: string
16 state: SwitchboardState
17 since: number
18 /** The heartbeat: a live session rewrites its record at least this often (HEARTBEAT_MS). */
19 updatedAt: number
20 costUsd?: number
21 where?: SwitchboardWhere
22 /** What a `needs-you` session waits on: "a permission for Bash", "a question". */
23 waitingOn?: string
24 /** When a turn last ran or a dialog last opened; absent when none has since Switchboard loaded. */
25 lastActiveAt?: number
26 /** A session opened and not prompted yet (no turn in its transcript). */
27 isNew?: boolean
28}
29
30/** A live session rewrites its record at least this often. */
31export const HEARTBEAT_MS = 30_000
32/** A record not rewritten for three heartbeats belongs to a session that is gone (closed, crashed, asleep). */
33export const STALE_MS = 3 * HEARTBEAT_MS
34/** A gone session stays listed this long, then drops off. */
35export const SHOWN_GONE_MS = 60 * 60_000
36/** An idle session with no turn for this long folds into one "quiet" line. */
37export const QUIET_MS = 60 * 60_000
38/** How often an attached session reads the others. */
39export const POLL_MS = 5_000
40/** The most records one read opens, newest first. */
41export const MAX_SESSIONS = 60
42
43const STATES: readonly string[] = ['working', 'needs-you', 'error', 'idle', 'gone']
44const WHERES: readonly string[] = ['terminal', 'desktop', 'background']
45
46/** A record read from disk, or null when it is not one (another version, a half-written file). */
47export function parseRecord(text: string): SessionRecord | null {
48 let value: unknown
49 try {
50 value = JSON.parse(text)
51 } catch {
52 return null
53 }
54 if (typeof value !== 'object' || value === null) return null
55 const r = value as Record<string, unknown>
56 if (r.v !== 1 || typeof r.id !== 'string' || r.id === '' || typeof r.name !== 'string') return null
57 if (typeof r.state !== 'string' || !STATES.includes(r.state)) return null
58 if (typeof r.since !== 'number' || typeof r.updatedAt !== 'number') return null
59 const cost = typeof r.costUsd === 'number' && Number.isFinite(r.costUsd) && r.costUsd >= 0 ? r.costUsd : undefined
60 return {
61 v: 1,
62 id: r.id,
63 name: r.name,
64 state: r.state as SwitchboardState,
65 since: r.since,
66 updatedAt: r.updatedAt,
67 ...(cost === undefined ? {} : { costUsd: cost }),
68 ...(typeof r.where === 'string' && WHERES.includes(r.where) ? { where: r.where as SwitchboardWhere } : {}),
69 ...(typeof r.waitingOn === 'string' && r.waitingOn !== '' ? { waitingOn: oneLine(r.waitingOn, 40) } : {}),
70 ...(typeof r.lastActiveAt === 'number' ? { lastActiveAt: r.lastActiveAt } : {}),
71 ...(r.isNew === true ? { isNew: true } : {}),
72 }
73}
74
75/** The state a reader shows: a record not rewritten lately is a session that is gone. */
76export function liveState(record: SessionRecord, now: number): SwitchboardState {
77 return now - record.updatedAt > STALE_MS ? 'gone' : record.state
78}
79
80/** Whether a row asks for the person: a dialog waiting, or a turn that ended on an error. */
81export const needsYou = (state: SwitchboardState): boolean => state === 'needs-you' || state === 'error'
82
83const ORDER: Record<SwitchboardState, number> = { 'needs-you': 0, error: 1, working: 2, idle: 3, gone: 4 }
84
85/**
86 * The rows the pane draws: who needs you first, then working, idle and recently
87 * gone, the latest change first in each; gone sessions drop off after an hour.
88 * An idle session with no turn in the last hour is `isQuiet`: the pane folds
89 * those into one line. Two sessions with one name are told apart by where they run.
90 */
91export function rowsOf(records: readonly SessionRecord[], mine: string, now: number): SwitchboardRow[] {
92 const latest = new Map<string, SessionRecord>()
93 for (const r of records) {
94 const had = latest.get(r.id)
95 if (had === undefined || r.updatedAt > had.updatedAt) latest.set(r.id, r)
96 }
97 const rows: SwitchboardRow[] = []
98 for (const r of latest.values()) {
99 const state = liveState(r, now)
100 const isMine = r.id === mine
101 if (state === 'gone' && now - r.updatedAt > SHOWN_GONE_MS && !isMine) continue
102 const lastActiveAt = r.lastActiveAt
103 rows.push({
104 id: r.id,
105 name: oneLine(r.name, 48) || 'session',
106 state,
107 since: state === 'gone' && r.state !== 'gone' ? r.updatedAt : r.since,
108 where: r.where ?? 'terminal',
109 ...(state === 'needs-you' && r.waitingOn !== undefined ? { waitingOn: r.waitingOn } : {}),
110 ...(r.costUsd === undefined ? {} : { costUsd: r.costUsd }),
111 // A session nobody has prompted yet is new, not quiet: someone just opened it.
112 isQuiet: !isMine && state === 'idle' && r.isNew !== true && (lastActiveAt === undefined || now - lastActiveAt > QUIET_MS),
113 isNew: state === 'idle' && r.isNew === true,
114 isMine,
115 })
116 }
117 const counts = new Map<string, number>()
118 for (const row of rows) counts.set(row.name, (counts.get(row.name) ?? 0) + 1)
119 return rows
120 .map(row => ((counts.get(row.name) ?? 0) > 1 ? { ...row, name: `${row.name} · ${row.id.slice(0, 4)}` } : row))
121 .sort((a, b) => ORDER[a.state] - ORDER[b.state] || Number(a.isQuiet) - Number(b.isQuiet) || b.since - a.since || a.name.localeCompare(b.name))
122}
123
124/**
125 * Sessions other than this one that just started needing you: a toast each,
126 * once per change. `seen` is the state each session had at the last read
127 * (undefined on the first read, which raises nothing: the band shows those).
128 */
129export function newlyNeeding(rows: readonly SwitchboardRow[], seen: ReadonlyMap<string, string> | undefined): SwitchboardRow[] {
130 if (seen === undefined) return []
131 return rows.filter(row => !row.isMine && needsYou(row.state) && seen.get(row.id) !== `${row.state}@${row.since}`)
132}
133
134/** What each row was at this read, for the next read's comparison. */
135export function seenOf(rows: readonly SwitchboardRow[]): Map<string, string> {
136 return new Map(rows.map(row => [row.id, `${row.state}@${row.since}`]))
137}
138
139/** The rows as they would draw, so a read that changed nothing on screen writes nothing (no redraw). */
140export function drawnKey(rows: readonly SwitchboardRow[], now: number): string {
141 return rows.map(r => `${r.id}|${r.name}|${r.where}|${r.isQuiet}|${stateWords(r, now)}|${costWords(r.costUsd)}|${r.isMine}`).join('\n')
142}
143
144/** Shape and colour together, never colour alone. */
145export const MARK: Record<SwitchboardState, { shape: string; color?: string; dimColor?: boolean }> = {
146 'needs-you': { shape: '!', color: 'warning' },
147 error: { shape: '✕', color: 'error' },
148 working: { shape: '●', color: 'success' },
149 idle: { shape: '○', dimColor: true },
150 gone: { shape: '-', dimColor: true },
151}
152
153/**
154 * What a dialog waits on, from Claude Code's notice type and the tool the turn
155 * last called: "a permission for Bash", "a question", "a plan to approve".
156 */
157export function waitingWords(notice: string, tool: string | undefined): string {
158 if (notice === 'elicitation_dialog' || notice === 'elicitation_url_dialog' || notice === 'agent_needs_input') return 'an answer'
159 // A subagent's dialog: the tool the main loop last called is not the one it asks about.
160 if (notice === 'worker_permission_prompt') return "a subagent's permission"
161 if (tool === 'AskUserQuestion') return 'a question'
162 if (tool === 'ExitPlanMode') return 'a plan to approve'
163 if (tool === undefined || tool === '') return 'a permission'
164 const short = tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool) : tool
165 return `a permission for ${oneLine(short, 24)}`
166}
167
168/** `under a minute`, `12m`, `3h`, `2d`: whole units, so the pane redraws at most once a minute. */
169export function ageWords(ms: number): string {
170 if (ms < 60_000) return 'under a minute'
171 if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`
172 if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h`
173 return `${Math.floor(ms / 86_400_000)}d`
174}
175
176/** A row's state as a phrase that explains itself: "idle for 9m", "closed 2m ago". */
177export function stateWords(row: SwitchboardRow, now: number): string {
178 const age = ageWords(now - row.since)
179 switch (row.state) {
180 case 'needs-you':
181 return `waiting on ${row.waitingOn ?? 'you'} · ${age}`
182 case 'error':
183 return age === 'under a minute' ? 'stopped on an error just now' : `stopped on an error ${age} ago`
184 case 'working':
185 return `working for ${age}`
186 case 'idle':
187 // A quiet row's own age is only since Switchboard loaded into it: say what is known instead.
188 return row.isNew ? 'new, no prompt yet' : row.isQuiet ? 'no turn in the last hour' : `idle for ${age}`
189 case 'gone':
190 return age === 'under a minute' ? 'closed just now' : `closed ${age} ago`
191 }
192}
193
194export const WHERE_WORDS: Record<SwitchboardWhere, string> = { terminal: 'terminal', desktop: 'desktop', background: 'background' }
195
196/** `≈$0.49`, `≈$123`, or '' when there is no figure or it is under a cent. */
197export function costWords(usd: number | undefined): string {
198 if (usd === undefined || usd < 0.01) return ''
199 return usd < 100 ? `≈$${usd.toFixed(2)}` : `≈$${Math.round(usd)}`
200}
201
202const others = (rows: readonly SwitchboardRow[]) => rows.filter(row => !row.isMine)
203const needing = (rows: readonly SwitchboardRow[]) => others(rows).filter(row => needsYou(row.state))
204
205/** The pane's first line: what needs you, else that nothing does. */
206export function heroLine(rows: readonly SwitchboardRow[]): { text: string; tone: 'warning' | 'subtle' | 'success' } {
207 const n = needing(rows).length
208 if (n > 0) return { text: `${n} need${n === 1 ? 's' : ''} you`, tone: 'warning' }
209 const live = others(rows).filter(row => row.state !== 'gone').length
210 if (live === 0) return { text: 'Only this session', tone: 'subtle' }
211 return { text: `Nothing needs you · ${live} other session${live === 1 ? '' : 's'}`, tone: 'success' }
212}
213
214/** The pane's pinned line about this session. */
215export function hereLine(rows: readonly SwitchboardRow[]): string | null {
216 const me = rows.find(row => row.isMine)
217 return me === undefined ? null : `You are here: ${me.name} (${WHERE_WORDS[me.where]})`
218}
219
220/** The folded line for idle sessions with no turn in the last hour. */
221export function quietLine(count: number): string {
222 return `${count} quiet session${count === 1 ? '' : 's'}: idle, no turn in the last hour`
223}
224
225export const EMPTY_LINE = 'Only this session runs Switchboard. Install it for all sessions to see the rest.'
226
227/** The band's one line while another session needs you, or null. */
228export function bandLine(rows: readonly SwitchboardRow[]): string | null {
229 const list = needing(rows)
230 const first = list[0]
231 if (first === undefined) return null
232 if (list.length === 1) return `${first.name} ${first.state === 'error' ? 'stopped on an error' : 'needs you'} (${WHERE_WORDS[first.where]})`
233 const names = list.slice(0, 2).map(r => r.name).join(', ')
234 return `${list.length} sessions need you: ${names}${list.length > 2 ? ', …' : ''}`
235}
236
237/** The footer's label while another session needs you, or null. */
238export function footerLabel(rows: readonly SwitchboardRow[]): string | null {
239 const n = needing(rows).length
240 return n === 0 ? null : `${n} need${n === 1 ? 's' : ''} you`
241}
242
243/** The toast for a session that just started needing you (at most 12 words). */
244export function toastLine(row: SwitchboardRow): string {
245 const where = WHERE_WORDS[row.where]
246 return row.state === 'error' ? `${row.name} stopped on an error (${where}).` : `${row.name} needs you (${where}).`
247}
248
249/** Text made safe to draw: no control characters, one line, cut to `max` with an ellipsis. */
250export function oneLine(text: string, max: number): string {
251 // eslint-disable-next-line no-control-regex
252 const flat = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim()
253 const chars = [...flat]
254 return chars.length <= max ? flat : `${chars.slice(0, Math.max(1, max - 1)).join('')}…`
255}
256
257/** A folder's last part, for a session with no title: `/work/api-server/` reads `api-server`. */
258export function folderName(path: string): string {
259 return path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
260}
261
262/** Where Claude Code keeps a session's transcript: the folder under `projects/` is the cwd with every non-alphanumeric character as `-`. */
263export function transcriptPath(configDir: string, cwd: string, id: string): string {
264 return `${configDir}/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
265}
266
267/**
268 * The session's title from the end of its transcript: the last title the
269 * person set (`custom-title`, the desktop sidebar's), else the last one Claude
270 * Code wrote (`ai-title`). Only those lines are parsed; every other line is
271 * skipped unread, and the first (cut mid-line by the tail) never parses.
272 */
273export function titleFromTail(tail: string): string | undefined {
274 let custom: string | undefined
275 let ai: string | undefined
276 for (const line of tail.split('\n')) {
277 if (!line.includes('-title"')) continue
278 try {
279 const entry = JSON.parse(line) as { type?: unknown; customTitle?: unknown; aiTitle?: unknown }
280 if (entry.type === 'custom-title' && typeof entry.customTitle === 'string' && entry.customTitle.trim() !== '') custom = entry.customTitle
281 if (entry.type === 'ai-title' && typeof entry.aiTitle === 'string' && entry.aiTitle.trim() !== '') ai = entry.aiTitle
282 } catch {
283 // The line the tail cut in half.
284 }
285 }
286 const title = custom ?? ai
287 return title === undefined ? undefined : oneLine(title, 48)
288}
289hooks/bolt-draw.tsx 86 lines1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13 Box: ElementConstructor<BoxProps>
14 Text: ElementConstructor<TextProps>
15 Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25 return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30 const { Box, Text, Raster } = elements
31 const cells = boltOf(pick)
32 const columns = cells[0]?.length ?? 0
33 if (surface === 'terminal' && Raster !== undefined) {
34 return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35 }
36 const paint = (cell: BoltCell): TextProps => ({
37 ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38 ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39 })
40 if (surface === 'desktop') {
41 return (
42 <Box key={key} flexDirection="column" flexShrink={0}>
43 {cells.map(line => (
44 <Box flexDirection="row" flexWrap="nowrap">
45 {line.map(cell =>
46 cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47 )}
48 </Box>
49 ))}
50 </Box>
51 )
52 }
53 return (
54 <Box key={key} flexDirection="column" flexShrink={0}>
55 {cells.map(line => (
56 <Text>
57 {boltRuns(line).map(run => {
58 const text = run.ch.replace(/ /g, ' ')
59 return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60 })}
61 </Text>
62 ))}
63 </Box>
64 )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69 const DEFAULT = 0x01000000
70 const count = cells.reduce((sum, line) => sum + line.length, 0)
71 const view = new DataView(new ArrayBuffer(count * 12))
72 let at = 0
73 for (const line of cells) {
74 for (const cell of line) {
75 view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76 view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77 view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78 at += 12
79 }
80 }
81 let binary = ''
82 const bytes = new Uint8Array(view.buffer)
83 for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84 return btoa(binary)
85}
86hooks/bolt.ts 110 lines1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7 B: '#C8F53C',
8 S: '#9BC21A',
9 O: '#FF5C8A',
10 K: '#FF8FB1',
11 E: '#101114',
12 W: '#FBFBF8',
13 G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'calm' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26 small: {
27 'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28 'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29 'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30 'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31 'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32 'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33 'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34 'calm': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
35 'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
36 'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
37 'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38 'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39 'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
40 'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
41 'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
42 'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
43 'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
44 },
45 tiny: {
46 'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
47 'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
48 'run2': ['.O.', 'BEB', 'BBB', '.S.'],
49 'jump': ['.O.', 'BEB', 'BBB', '.S.'],
50 'crash': ['...', '.SO', 'BEB', 'SSS'],
51 'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
52 'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
53 },
54} as const
55
56/** Letters drawn over a whole cell (cell coordinates). */
57const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
58 'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
59}
60
61/** The two run frames, in order. Swap them about every 120 ms while running. */
62export const BOLT_RUN = ['run1', 'run2'] as const
63
64function colour(key: string | undefined): string | undefined {
65 if (key === undefined || key === '.') return undefined
66 return (BOLT_PALETTE as Record<string, string>)[key]
67}
68
69/**
70 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
71 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
72 */
73export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
74export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
75export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
76 const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
77 const out: BoltCell[][] = []
78 for (let r = 0; r + 1 < rows.length; r += 2) {
79 const top = rows[r] ?? ''
80 const bottom = rows[r + 1] ?? ''
81 const line: BoltCell[] = []
82 for (let c = 0; c < top.length; c += 1) {
83 const t = colour(top[c])
84 const b = colour(bottom[c])
85 if (t === undefined && b === undefined) line.push({ ch: ' ' })
86 else if (b === undefined) line.push({ ch: '\u2580', fg: t })
87 else if (t === undefined) line.push({ ch: '\u2584', fg: b })
88 else if (t === b) line.push({ ch: '\u2588', fg: t })
89 else line.push({ ch: '\u2580', fg: t, bg: b })
90 }
91 out.push(line)
92 }
93 for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
94 const line = out[glyph.row]
95 if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
96 }
97 return out
98}
99
100/** Merges a row of cells into runs of one style, for coloured Text spans. */
101export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
102 const runs: BoltCell[] = []
103 for (const cell of line) {
104 const last = runs[runs.length - 1]
105 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
106 else runs.push({ ...cell })
107 }
108 return runs
109}
110types/index.d.ts 44 lines1// What Switchboard keeps in $.state: the sessions it last read from the shared
2// folder, as the pane, band and footer draw them, and whether the quiet ones
3// are unfolded. Each session's own record lives in a small file under
4// ~/.claude/switchboard, written by that session itself.
5
6/** What a session is doing, as it reports it. `gone` is also inferred from a stale record. */
7export type SwitchboardState = 'working' | 'needs-you' | 'error' | 'idle' | 'gone'
8
9/** Where a session runs, so the person knows where to go: a terminal, the desktop app, or nothing attached. */
10export type SwitchboardWhere = 'terminal' | 'desktop' | 'background'
11
12/** One session as the pane draws it. */
13export type SwitchboardRow = {
14 id: string
15 /** The session's title, else its folder's name; a short id is added when two read the same. */
16 name: string
17 state: SwitchboardState
18 /** When this state began, ms since the epoch. */
19 since: number
20 where: SwitchboardWhere
21 /** What a `needs-you` session waits on, when it said. */
22 waitingOn?: string
23 /** What the session has cost so far, when Claude Code keeps a ledger. */
24 costUsd?: number
25 /** Idle with no turn in the last hour: folded into one line. */
26 isQuiet: boolean
27 /** Opened and not prompted yet: listed (never folded), and said so. */
28 isNew: boolean
29 /** True for the session this pane is drawn in. */
30 isMine: boolean
31}
32
33declare module 'claude-code' {
34 interface PluginState {
35 switchboard: {
36 rows: SwitchboardRow[]
37 /** When the rows were read, ms since the epoch (their ages are counted from it). */
38 readAt: number
39 /** The person unfolded the quiet sessions. */
40 showQuiet: boolean
41 }
42 }
43}
44