SLOPSHOPPER

switchboard

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

newpanebandspinnerguardcommand
★ 1v0.1.0MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/switchboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · switchboard
│ ┃ Switchboard ✕ › fix the failing auth test and add an audit log call │ ┃ ▄█▄ Only this session │ ┃ ▀▀▀ ⏺ Read(src/auth.ts) │ ┃ You are here: app (terminal) ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Only this session runs Switchboard. Install ⎿ Added 2 lines, removed 1 line │ ┃ it for all sessions to see the rest. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ It only watches: go to a session to answer │ ┃ it. Sessions show up once Switchboard runs ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ in them. │ ✻ Worked for 42s · done 4:20 PM │ │ › /switchboard │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Switchboard
▄█▄ Only this session ▀▀▀ You are here: app (terminal) Only this session runs Switchboard. Install it for all sessions to see the rest. It only watches: go to a session to answer it. Sessions show up once Switchboard runs in them.
README

<img src="../../brand/pixel-icons/svg/switchboard.svg" width="64" height="64" alt="">

Switchboard

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

Install

/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.

Use 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.
  • One row per session, who needs you first: a mark and a colour for its state, its name (the title in the desktop sidebar, or the one Claude Code gives it, else its folder), what it is doing in words, where it runs, and its cost so far when Claude Code keeps that figure.
  • ! 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)
  • Quiet sessions (idle, with no turn in the last hour) fold into one line. Show unfolds them; each new start of the session folds them again.
  • A session waiting on you says what for on a line of its own. On a narrow pane the names shorten first, so the state and where always show.
  • While another session needs you, a line above your prompt says which and where (! 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.
  • A toast the moment another session starts needing you, once per change: 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.

Settings

  • On: on by default. Off, Switchboard writes no record, reads no other session's and draws no line, footer count or rows; switched off while it runs, its record says closed one last time, so other sessions stop showing this one working or waiting on you. /switchboard says it is off. /config or the modmyclaude hub flips it, live.

What it can touch

  • Writes: one small file for this session, ~/.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.
  • Reads: the other files in that folder, every 5 seconds while you are looking at this session (a terminal or the desktop app attached) and whenever this session's own state or title changes, only the ones that changed since the last read. Its own session's events: turns starting and ending, the tool a turn calls, Claude Code's own "waiting on you" notice, the session title, and the cost figure.
  • Runs: 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.
  • Session state: the rows as last read, and whether the quiet ones are unfolded.
  • Network, model calls: none.

Limits

  • A session shows up only once Switchboard runs in it. Sessions started before you installed it appear after their next restart, or at once where Claude Code hot-loads the mod into them.
  • A dialog shows as waiting about six seconds after it opens, when Claude Code raises its own notice; once you answer it, the row says working again after the tool it asked about has run.
  • A closed session is inferred: one that has not rewritten its file for 90 seconds is shown as closed. A computer waking from sleep shows its sessions as closed for up to half a minute.
  • A brand-new session shows its folder's name until Claude Code titles it, usually after the first prompt. On a host with no tail (Windows), the title comes only with your next prompt.
  • Files of old sessions stay in the folder (a few hundred bytes each): Claude Code gives mods no way to delete a file. They are never read once older than an hour, and you can delete the folder at any time.
  • Two sessions with the same name get the first characters of their ids to tell them apart.
  • An older session that has had no turn since Switchboard loaded into it counts as quiet, since its last turn is unknown.
  • The pane, the line and the footer are checked through the engine's test kit on the terminal and desktop surfaces.

Built from a prompt

This mod was built from the Switchboard prompt.

More mods: the full catalogue.

Source 5 files
hooks/register.tsx 607 lines
1// 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}
607
hooks/board.ts 289 lines
1// 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}
289
hooks/bolt-draw.tsx 86 lines
1// 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}
86
hooks/bolt.ts 110 lines
1// 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}
110
types/index.d.ts 44 lines
1// 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