A Solari split-flap departure board for the agent's real work: tasks flip letter by letter in amber as they board, depart, get delayed or cancelled


Claude makes a 4-step task list and the split-flap band cascades each row from BOARDING to DEPARTED. Watch the MP4
A Solari split-flap departure board for what Claude is actually doing. Every character is a flap that falls through the drum, letter by letter in amber on black, at about 30 fps, with a left-to-right cascade.
TIME · DESTINATION · PLATFORM · STATUS. The platform is the file or tool Claude is on right now.ON TIME (pending) → BOARDING (in progress) → DEPARTED (done). An error while boarding turns it DELAYED, and an interrupted or deleted item is CANCELLED./board opens the full board in a pane./board band off hides the mini board, and /board band on brings it back.clack in the plugin settings. It's off by default, plays on macOS only, and the sound is self-made.The clock only runs while flaps are falling, so an idle board costs nothing. On surfaces without cell graphics (desktop) the board is drawn as text.
/plugin marketplace add ccdwyer/claude-mods
/plugin install departure-board@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
session.start: registers /board.command.run (/board): opens the pane or toggles the mini board.prompt.submit and turn.start: a prompt you type becomes a departure when its turn starts (when there's no live task list).tool.call: watches TodoWrite, TaskCreate and TaskUpdate for the rows, and every other call for the platform column and errors. It never changes, refuses or annotates a call.turn.complete: a turn's departure becomes DEPARTED, CANCELLED or stays DELAYED.ui.render (AbovePrompt, Pane): draws the board. The band is drawn above whatever other mods draw there.ui.close: forgets the pane's animation state.It runs entirely on your machine. It sends nothing over the network. It reads Claude's task list and tool calls in this session only.
Full policy: PRIVACY.md.
MIT
hooks/register.tsx 490 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BoardRow, BoardStatus } from '../types'
5import { Flaps, boardOrder, clockTime, isLive, layout, platformOf, statusOf, targetGrid, todoIds, trim } from './board'
6
7const rows = atom({ plugin: 'departure-board', key: 'rows' } as const, [])
8const isBandHidden = atom({ plugin: 'departure-board', key: 'isBandHidden' } as const, false)
9
10const PANE = 'departure-board'
11const FRAME_MS = 33
12const CLACK = 'assets/clack.wav'
13// The narrowest board the layout fits (time, destination, status and their gaps).
14const MIN_COLUMNS = 21
15// Prompts that are a person's own words: each one is a new departure when no task list exists.
16const PERSON = ['composer', 'bridge', 'sdk', 'channel', 'slack-ping']
17const TASK_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList'])
18// The inline pane asks to be as tall as its departures, between these bounds; a longer board scrolls its oldest away.
19const MIN_PANE_ROWS = 6
20const MAX_PANE_ROWS = 30
21// Rows around the departures: the title, the board's header row and the footer.
22const PANE_CHROME_ROWS = 3
23
24type Site = { requestId: string; key: string; flaps: Flaps }
25type Call = { tool: string; [k: string]: unknown }
26type TaskRow = { id?: string; subject?: string; status?: string }
27
28// What is on screen, per drawing site. Rebuilt after a reload by the next draw.
29const sites = new Map<string, Site>()
30// The next frame, when one is due: frames are chained, so a slow blit drops a frame instead of stacking steps.
31let pending: { cancel: () => void } | null = null
32let isFraming = false
33let lastClack = 0
34let isClackOn = false
35// A person's prompts that have entered, waiting for the turn that carries them to start.
36const waitingPrompts: string[] = []
37// The main loop's running turn, so a plain tool call belongs to its row.
38let runningTurn: string | null = null
39// While the pane is open, the body rows it last asked for: a longer board asks again.
40let askedRows: number | null = null
41// The height of the tree the pane drew last: an inline host reports min(granted, measured), so only a report
42// smaller than what was drawn is a real, smaller window.
43let lastPaneHeight = 0
44
45// The turn whose prompt started the current task list: its row leaves once the list has something on its way.
46let listOwner: string | null = null
47
48/** A task list is "on" while some todo or task is still to go; a finished list gives the board back to the turns. */
49function hasLiveTaskList(all: BoardRow[]): boolean {
50 return all.some(r => r.source !== 'turn' && (r.status === 'ON TIME' || r.status === 'BOARDING' || r.status === 'DELAYED'))
51}
52
53/** The rows a board shows: the live task list when there is one, else everything (turns and finished items). */
54function shownRows(all: BoardRow[]): BoardRow[] {
55 // The turn that started the list stays up until the list has something on its way (then it leaves the board).
56 return hasLiveTaskList(all) ? all.filter(r => r.source !== 'turn' || r.id === listOwner) : all
57}
58
59/** The list's creator leaves the board once the list has an item on its way; until then it keeps the platform and errors. */
60function absorbOwner(list: BoardRow[]): BoardRow[] {
61 if (listOwner === null) return list
62 const id = listOwner
63 return list.some(r => r.source !== 'turn' && isLive(r)) ? list.filter(r => r.id !== id) : list
64}
65
66const normalize = (text: string) => text.replace(/\s+/g, ' ').trim()
67
68/** One frame: step every board, then paint them; chain the next frame only while flaps are falling. */
69async function frame($: EngineInterface): Promise<void> {
70 pending = null
71 if (isFraming) return
72 isFraming = true
73 try {
74 const painted: { id: string; site: Site; cells: string }[] = []
75 for (const [id, site] of sites) {
76 if (site.flaps.step()) painted.push({ id, site, cells: site.flaps.encode() })
77 }
78 for (const p of painted) {
79 try {
80 const done = await $.ui.blit({ requestId: p.site.requestId, key: p.site.key, cells: p.cells })
81 // Only forget the site this frame painted: a render may have replaced it while the blit was in flight.
82 if (done.deny !== undefined && sites.get(p.id) === p.site) sites.delete(p.id)
83 } catch {
84 // Nothing mounted there any more (or no surface at all): forget the site, if it is still this one.
85 if (sites.get(p.id) === p.site) sites.delete(p.id)
86 }
87 }
88 if (painted.length > 0) schedule($)
89 } finally {
90 isFraming = false
91 }
92}
93
94/** Ask for the next frame if a board has flaps still falling, with one clack per burst. */
95function schedule($: EngineInterface): void {
96 if (pending !== null) return
97 let busy = false
98 for (const site of sites.values()) if (!site.flaps.settled) busy = true
99 if (!busy) return
100 pending = $.clock.after(FRAME_MS, () => void frame($))
101 const now = Date.now()
102 if (isClackOn && now - lastClack > 800) {
103 lastClack = now
104 void clack($)
105 }
106}
107
108/** One mechanical clack; a terminal with no player plays nothing. */
109async function clack($: EngineInterface): Promise<void> {
110 try {
111 await $.audio.play({ asset: CLACK })
112 } catch {
113 // No sound is fine.
114 }
115}
116
117/** Keep the site's flaps, falling towards a new target; a resized site starts over. */
118function place(id: string, requestId: string, key: string, columns: number, height: number, list: BoardRow[], header: boolean): Site {
119 const target = targetGrid(list, columns, height, header)
120 const was = sites.get(id)
121 if (was !== undefined && was.flaps.columns === columns && was.flaps.rows === height && was.requestId === requestId) {
122 was.flaps.retarget(target)
123 return was
124 }
125 const site = { requestId, key, flaps: new Flaps(columns, height, target) }
126 sites.set(id, site)
127 return site
128}
129
130/** The body rows the pane wants for this board: every shown departure plus its title, header row and footer. */
131export function wantedRows(all: BoardRow[]): number {
132 return Math.min(MAX_PANE_ROWS, Math.max(MIN_PANE_ROWS, shownRows(all).length + PANE_CHROME_ROWS))
133}
134
135async function openBoard($: EngineInterface): Promise<boolean> {
136 try {
137 const want = wantedRows(await read($, rows))
138 const opened = await $.ui.open({ id: PANE, title: 'Departures', rows: want })
139 askedRows = opened.isPlaced ? want : null
140 return opened.isPlaced
141 } catch {
142 return false
143 }
144}
145
146/** While the pane is open, ask for more rows once the board has outgrown what it asked for. */
147async function growBoard($: EngineInterface): Promise<void> {
148 try {
149 if (askedRows === null) {
150 // After a reload the module forgot what it asked for; a pane still on screen is still open.
151 const panes = await $.ui.panes()
152 const open = panes.some(p => p.id === PANE && p.isPlaced)
153 if (!open) return
154 askedRows = wantedRows(await read($, rows))
155 return
156 }
157 const want = wantedRows(await read($, rows))
158 if (want <= askedRows) return
159 const opened = await $.ui.open({ id: PANE, title: 'Departures', rows: want })
160 if (opened.isPlaced) askedRows = want
161 } catch {
162 // The board keeps its size; it still scrolls the oldest away.
163 }
164}
165
166/** The row a plain tool call belongs to: the newest work item on its way, else the running turn. */
167function currentWork(list: BoardRow[]): string | null {
168 let best: BoardRow | null = null
169 for (const r of list) {
170 if (r.source === 'turn' || !isLive(r)) continue
171 if (best === null || r.at > best.at) best = r
172 }
173 if (best !== null) return best.id
174 const turn = runningTurn === null ? undefined : list.find(r => r.id === `turn:${runningTurn}` && r.isActive)
175 return turn?.id ?? null
176}
177
178type Next = (e: never) => Promise<{ deny?: string; isError?: true }>
179
180/**
181 * A plain tool call: it becomes the platform of the row on its way as it starts, and an error
182 * delays that same row, even if another item started meanwhile.
183 */
184async function plainCall($: EngineInterface, e: Call, next: Next): Promise<never> {
185 let owner: string | null = null
186 try {
187 const platform = platformOf(e)
188 const now = await $.clock.now()
189 await update($, rows, list => {
190 owner = currentWork(list)
191 if (owner === null) return list
192 return list.map(r => (r.id === owner ? { ...r, platform, at: now } : r))
193 })
194 } catch {
195 owner = null
196 }
197 const ran = await next(e as never)
198 if (owner !== null && ran.deny === undefined && ran.isError === true) {
199 try {
200 const id = owner
201 await update($, rows, list =>
202 list.some(r => r.id === id && isLive(r)) ? list.map(r => (r.id === id ? { ...r, isTroubled: true, status: 'DELAYED' as const } : r)) : list,
203 )
204 } catch {
205 // Ignore.
206 }
207 }
208 return ran as never
209}
210
211const clip = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(0, room - 1))}…` : text)
212
213export const register: Register = (on, options) => {
214 isClackOn = options.clack === true
215
216 on('session.start', async ($, e, next) => {
217 try {
218 await $.command.register({
219 name: 'board',
220 description: 'Departure Board: open the split-flap board (`/board band off` hides the mini board)',
221 argumentHint: '[band on|off]',
222 immediate: true,
223 })
224 } catch {
225 // The board is optional; the session is not.
226 }
227 return next(e)
228 })
229
230 on('command.run', { command: 'board' }, async ($, e) => {
231 const arg = e.args.trim().toLowerCase()
232 if (arg === 'band off' || arg === 'band on') {
233 await update($, isBandHidden, () => arg === 'band off')
234 return { text: `Departure Board: mini board ${arg === 'band off' ? 'hidden' : 'shown'}.` }
235 }
236 const placed = await openBoard($)
237 return { text: placed ? 'Departure Board: now boarding.' : 'Departure Board: widen the terminal to open the board.' }
238 })
239
240 on('ui.close', { id: PANE }, async ($, e, next) => {
241 sites.delete(PANE)
242 askedRows = null
243 lastPaneHeight = 0
244 return next(e)
245 })
246
247 // A person's prompt that entered (now, or queued behind the running turn) is remembered;
248 // its row opens when the turn that carries it starts, so a prompt typed mid-turn never takes over the live row.
249 on('prompt.submit', async ($, e, next) => {
250 const res = await next(e)
251 if (res.drop !== undefined || !PERSON.includes(e.origin.kind)) return res
252 const words = normalize(e.text)
253 if (words.length > 0 && !words.startsWith('/')) {
254 waitingPrompts.push(words)
255 if (waitingPrompts.length > 20) waitingPrompts.shift()
256 }
257 return res
258 })
259
260 on('turn.start', async ($, e, next) => {
261 const res = await next(e)
262 try {
263 const words = normalize(e.text)
264 const at = waitingPrompts.indexOf(words)
265 // Only a turn carrying a person's prompt departs; continuations and notifications do not.
266 if (words.length === 0 || at < 0) return res
267 waitingPrompts.splice(at, 1)
268 runningTurn = e.turnId
269 const now = await $.clock.now()
270 const row: BoardRow = { id: `turn:${e.turnId}`, time: clockTime(now), destination: words, platform: '', status: 'BOARDING', source: 'turn', at: now, isTroubled: false, isActive: true }
271 await update($, rows, list => trim([...list.filter(r => r.id !== row.id), row]))
272 await growBoard($)
273 } catch {
274 // Ignore.
275 }
276 return res
277 })
278
279 on('tool.call', async ($, e, next) => {
280 // A plain call (any agent's) belongs to the row on its way when it starts, not when it ends.
281 if (!TASK_TOOLS.has(e.tool)) return plainCall($, e as Call, next)
282 const ran = await next(e)
283 // A subagent's own task list is not the session's board.
284 if (ran.deny !== undefined || e.agentId !== undefined) return ran
285 try {
286 const now = await $.clock.now()
287 const failed = ran.isError === true
288
289 // A task list stands in for the prompt that started it: only that turn (not one that later updates the list).
290 const madeBy = runningTurn === null ? null : `turn:${runningTurn}`
291 const startsList = (list: BoardRow[]) => !hasLiveTaskList(list)
292
293 if (e.tool === 'TodoWrite') {
294 if (failed) return ran
295 const todos = Array.isArray(e.todos) ? e.todos : []
296 const ids = todoIds(todos.map(t => t.content))
297 await update($, rows, list => {
298 if (todos.length > 0 && startsList(list)) listOwner = madeBy
299 const keep = list.filter(r => r.source !== 'todo')
300 const before = new Map(list.filter(r => r.source === 'todo').map(r => [r.id, r]))
301 const fresh = todos.map((t, i): BoardRow => {
302 const id = ids[i] as string
303 const was = before.get(id)
304 // The red mark lasts while the item stays in progress.
305 const isTroubled = t.status === 'in_progress' && was?.isTroubled === true
306 const status = statusOf(t.status, isTroubled)
307 return {
308 id,
309 time: was?.time ?? clockTime(now),
310 destination: t.status === 'in_progress' && t.activeForm ? t.activeForm : t.content,
311 platform: was?.platform ?? '',
312 status,
313 source: 'todo',
314 at: was !== undefined && was.status === status ? was.at : now,
315 isTroubled,
316 isActive: false,
317 }
318 })
319 return trim(absorbOwner([...keep, ...fresh]))
320 })
321 await growBoard($)
322 return ran
323 }
324
325 if (e.tool === 'TaskCreate') {
326 const task = failed ? undefined : (ran.result as { task?: TaskRow } | undefined)?.task
327 if (task?.id !== undefined) {
328 const row: BoardRow = { id: `task:${task.id}`, time: clockTime(now), destination: task.subject ?? e.subject, platform: '', status: 'ON TIME', source: 'task', at: now, isTroubled: false, isActive: false }
329 await update($, rows, list => {
330 if (startsList(list)) listOwner = madeBy
331 return trim(absorbOwner([...list.filter(r => r.id !== row.id), row]))
332 })
333 await growBoard($)
334 }
335 return ran
336 }
337
338 if (e.tool === 'TaskUpdate') {
339 if (failed) return ran
340 const id = `task:${e.taskId}`
341 await update($, rows, list => {
342 const was = list.find(r => r.id === id)
343 // A task from before this session: it joins the board on its first update.
344 const base: BoardRow = was ?? { id, time: clockTime(now), destination: e.subject ?? `TASK ${e.taskId}`, platform: '', status: 'ON TIME', source: 'task', at: now, isTroubled: false, isActive: false }
345 const isTroubled = e.status === undefined ? base.isTroubled : e.status === 'in_progress' && base.isTroubled
346 const status: BoardStatus = e.status === undefined ? base.status : statusOf(e.status, isTroubled)
347 const row = { ...base, destination: e.subject ?? base.destination, status, isTroubled, at: now }
348 return trim(absorbOwner([...list.filter(r => r.id !== id), row]))
349 })
350 await growBoard($)
351 return ran
352 }
353
354 if (e.tool === 'TaskList') {
355 const snapshot = failed ? undefined : (ran.result as { tasks?: TaskRow[] } | undefined)?.tasks
356 if (Array.isArray(snapshot)) {
357 const tasks = snapshot
358 await update($, rows, list => {
359 const byId = new Map(list.map(r => [r.id, r]))
360 const listed = tasks.flatMap((t): BoardRow[] => {
361 if (t.id === undefined) return []
362 const id = `task:${t.id}`
363 const was = byId.get(id)
364 const isTroubled = t.status === 'in_progress' && was?.isTroubled === true
365 const status = statusOf(t.status ?? 'pending', isTroubled)
366 return [{ id, time: was?.time ?? clockTime(now), destination: t.subject ?? was?.destination ?? `TASK ${t.id}`, platform: was?.platform ?? '', status, source: 'task', at: was !== undefined && was.status === status ? was.at : now, isTroubled, isActive: false }]
367 })
368 const ids = new Set(listed.map(r => r.id))
369 // A full, successful snapshot: tasks it no longer lists were deleted, so they leave the board.
370 return trim(absorbOwner([...list.filter(r => !ids.has(r.id) && r.source !== 'task'), ...listed]))
371 })
372 await growBoard($)
373 }
374 return ran
375 }
376 } catch {
377 // The board only watches; the call's result stands.
378 }
379 return ran
380 })
381
382 on('turn.complete', async ($, e, next) => {
383 if (e.agentId === undefined) {
384 try {
385 const now = await $.clock.now()
386 const end: BoardStatus = e.reason === 'answer' ? 'DEPARTED' : 'CANCELLED'
387 const id = `turn:${e.turnId}`
388 if (runningTurn === e.turnId) runningTurn = null
389 // Only this turn's own row ends: a DELAYED turn that ends still departs (or is cancelled).
390 await update($, rows, list => (list.some(r => r.id === id) ? list.map(r => (r.id === id ? { ...r, status: end, isActive: false, at: now } : r)) : list))
391 await growBoard($)
392 } catch {
393 // Ignore.
394 }
395 }
396 return next(e)
397 })
398
399 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
400 const all = await read($, rows)
401 const room = Math.min(3, e.props.maxRows)
402 if (e.props.hasSurvey || all.length === 0 || room < 1 || (await read($, isBandHidden))) return next(e)
403 const list = shownRows(all)
404 const picked = boardOrder(list, Math.min(room, list.length))
405 // Reserve the board's rows: the mods beneath get what is left of the band.
406 const below = await next({ ...e, props: { ...e.props, maxRows: e.props.maxRows - picked.length } })
407 const columns = Math.min(512, e.props.bodyColumns)
408
409 if (e.surface !== 'terminal' || columns < MIN_COLUMNS) {
410 const { Box, Text } = $.ui.resolve(e)
411 return (
412 <Box flexDirection="column">
413 {picked.map(r => (
414 <Text>{clip(`${r.status} ${r.time} ${r.destination}`, Math.max(10, columns))}</Text>
415 ))}
416 {below}
417 </Box>
418 )
419 }
420
421 const { Box, Raster } = $.ui.resolve(e)
422 const site = place('band', e.requestId, 'mini', columns, picked.length, picked, false)
423 schedule($)
424 return (
425 <Box flexDirection="column">
426 <Raster key="mini" columns={columns} rows={picked.length} cells={site.flaps.encode()} />
427 {below}
428 </Box>
429 )
430 })
431
432 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
433 const all = shownRows(await read($, rows))
434 const { Box, Text } = $.ui.resolve(e)
435 const columns = Math.min(512, e.props.bodyColumns)
436 // The pane's own body, less the title and the footer: never the whole terminal.
437 const given = e.props.scroll?.bodyRows ?? 0
438 const asked = askedRows ?? wantedRows(await read($, rows))
439 // Inline, lay out to the height asked for (the host may report the body it measured, not the rows it granted);
440 // a smaller real window wins. Docked, the body is the real allocation.
441 const body = e.props.placement === 'inline' ? (given > 0 && given < lastPaneHeight ? given : asked) : given > 0 ? given : Math.max(6, Math.floor((e.viewport?.rows ?? 24) / 3))
442 const isRaster = e.surface === 'terminal' && columns >= MIN_COLUMNS
443 // Short of room for every departure, drop the title line and give its row to the board; tighter still, the footer.
444 const isCompact = body < all.length + PANE_CHROME_ROWS
445 const hasFooter = body >= (isRaster ? 3 : 2)
446 const boardRows = Math.max(1, Math.min(256, body - (isCompact ? 0 : 1) - (hasFooter ? 1 : 0)))
447 // The flap board spends one row on its column header; the text board does not.
448 const list = boardOrder(all, Math.max(0, isRaster ? boardRows - 1 : boardRows))
449 const boarding = all.filter(r => r.status === 'BOARDING').length
450 const departed = all.filter(r => r.status === 'DEPARTED').length
451 const delayed = all.filter(r => r.status === 'DELAYED').length
452 const more = all.length - list.length
453 const footer = `${all.length} departures · ${boarding} boarding · ${departed} departed · ${delayed} delayed${more > 0 ? ` · ${more} not shown` : ''}`
454
455 if (!isRaster) {
456 lastPaneHeight = (isCompact ? 0 : 1) + Math.max(1, list.length) + (hasFooter ? 1 : 0)
457 const l = layout(Math.max(MIN_COLUMNS, columns))
458 return (
459 <Box flexDirection="column">
460 {!isCompact && <Text bold>DEPARTURES</Text>}
461 {list.length === 0 && <Text dimColor>No departures yet. Give Claude a task.</Text>}
462 {list.map(r => (
463 <Text>
464 {r.time} {clip(r.destination, Math.max(20, l.destination))} · {clip(r.platform, 24)} · {r.status}
465 </Text>
466 ))}
467 {hasFooter && <Text dimColor>{footer}</Text>}
468 </Box>
469 )
470 }
471
472 const { Raster } = $.ui.resolve(e)
473 const height = Math.max(1, Math.min(boardRows, list.length + 1))
474 lastPaneHeight = (isCompact ? 0 : 1) + height + (hasFooter ? 1 : 0)
475 const site = place(PANE, e.requestId, 'board', columns, height, list, true)
476 schedule($)
477 return (
478 <Box flexDirection="column">
479 {!isCompact && (
480 <Text bold color="#ffb000">
481 ✈ DEPARTURES
482 </Text>
483 )}
484 <Raster key="board" columns={columns} rows={height} cells={site.flaps.encode()} />
485 {hasFooter && <Text dimColor>{list.length === 0 ? 'No departures yet. Give Claude a task.' : `${isCompact ? '✈ ' : ''}${footer}`}</Text>}
486 </Box>
487 )
488 })
489}
490hooks/board.ts 299 lines1// Pure board logic: rows to a character grid, and the split-flap animator.
2import type { BoardRow, BoardStatus } from '../types'
3
4// The characters on a flap drum, in the order the flaps fall.
5export const DRUM = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:-/'&()!?#+@_"
6const DRUM_INDEX = new Map<number, number>()
7for (let i = 0; i < DRUM.length; i += 1) DRUM_INDEX.set(DRUM.charCodeAt(i), i)
8
9export const AMBER = 0xffb000
10export const FLAP_BG = 0x161616
11export const GAP_BG = 0x050505
12const FLIPPING = 0xffe0a0
13const DEFAULT = 0x01000000
14
15export const STATUS_COLOR: Record<BoardStatus, number> = {
16 'ON TIME': 0xd8d8d8,
17 BOARDING: 0xffd000,
18 DEPARTED: 0x6be675,
19 DELAYED: 0xff5a4f,
20 CANCELLED: 0x8a8a8a,
21}
22
23/** One printable drum character: upper case, anything off the drum becomes a space. */
24export function drumText(text: string): string {
25 let out = ''
26 for (const ch of text.toUpperCase()) {
27 out += DRUM_INDEX.has(ch.charCodeAt(0)) && ch.length === 1 ? ch : ' '
28 }
29 return out
30}
31
32const pad = (text: string, width: number) => {
33 const t = drumText(text)
34 return t.length >= width ? t.slice(0, width) : t + ' '.repeat(width - t.length)
35}
36
37export type Layout = { time: number; destination: number; platform: number; status: number }
38
39/** Column widths for a board this many cells wide; the platform column goes first on narrow boards. */
40export function layout(columns: number): Layout {
41 const time = 5
42 const status = 9
43 const platform = columns >= 48 ? Math.min(16, Math.max(8, Math.floor(columns * 0.2))) : 0
44 const gaps = platform > 0 ? 3 : 2
45 const destination = Math.max(4, columns - time - status - platform - gaps)
46 return { time, destination, platform, status }
47}
48
49/** A target cell: its character and the colour it settles in. */
50export type Cell = { ch: number; fg: number; bg: number }
51
52/** The board as rows of cells: an optional header, then one line per departure. */
53export function targetGrid(rows: BoardRow[], columns: number, height: number, header: boolean): Cell[] {
54 const l = layout(columns)
55 const cells: Cell[] = []
56 const line = (parts: { text: string; width: number; fg: number }[], isHeader: boolean) => {
57 let used = 0
58 parts.forEach((p, i) => {
59 if (p.width <= 0) return
60 if (i > 0 && used < columns) {
61 cells.push({ ch: 32, fg: DEFAULT, bg: GAP_BG })
62 used += 1
63 }
64 const text = pad(p.text, p.width)
65 for (let k = 0; k < text.length && used < columns; k += 1) {
66 cells.push({ ch: text.charCodeAt(k), fg: p.fg, bg: isHeader ? GAP_BG : FLAP_BG })
67 used += 1
68 }
69 })
70 while (used < columns) {
71 cells.push({ ch: 32, fg: DEFAULT, bg: GAP_BG })
72 used += 1
73 }
74 }
75 let drawn = 0
76 if (header && height > 0) {
77 line(
78 [
79 { text: 'TIME', width: l.time, fg: 0x8a6a20 },
80 { text: 'DESTINATION', width: l.destination, fg: 0x8a6a20 },
81 { text: 'PLATFORM', width: l.platform, fg: 0x8a6a20 },
82 { text: 'STATUS', width: l.status, fg: 0x8a6a20 },
83 ],
84 true,
85 )
86 drawn += 1
87 }
88 for (const r of rows) {
89 if (drawn >= height) break
90 line(
91 [
92 { text: r.time, width: l.time, fg: AMBER },
93 { text: r.destination, width: l.destination, fg: AMBER },
94 { text: r.platform, width: l.platform, fg: AMBER },
95 { text: r.status, width: l.status, fg: STATUS_COLOR[r.status] },
96 ],
97 false,
98 )
99 drawn += 1
100 }
101 while (drawn < height) {
102 // An empty flap row: blank tiles, so the board keeps its shape.
103 line(
104 [
105 { text: '', width: l.time, fg: AMBER },
106 { text: '', width: l.destination, fg: AMBER },
107 { text: '', width: l.platform, fg: AMBER },
108 { text: '', width: l.status, fg: AMBER },
109 ],
110 false,
111 )
112 drawn += 1
113 }
114 return cells
115}
116
117/** Standard padded base64; no reliance on the environment's encoders. */
118const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
119export function base64(bytes: Uint8Array): string {
120 let out = ''
121 let i = 0
122 for (; i + 2 < bytes.length; i += 3) {
123 const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8) | (bytes[i + 2] as number)
124 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
125 }
126 const rest = bytes.length - i
127 if (rest === 1) {
128 const n = (bytes[i] as number) << 16
129 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + '=='
130 } else if (rest === 2) {
131 const n = ((bytes[i] as number) << 16) | ((bytes[i + 1] as number) << 8)
132 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + '='
133 }
134 return out
135}
136
137/**
138 * A board on screen: what each flap shows now, what it is falling towards, and
139 * how many frames each waits before it starts (the cascade).
140 */
141export class Flaps {
142 readonly columns: number
143 readonly rows: number
144 private shown: Uint16Array
145 private fg: Uint32Array
146 private target: Cell[]
147 private wait: Int16Array
148
149 constructor(columns: number, rows: number, target: Cell[]) {
150 this.columns = columns
151 this.rows = rows
152 const n = columns * rows
153 // A fresh board starts blank and flips in.
154 this.shown = new Uint16Array(n).fill(32)
155 this.fg = new Uint32Array(n).fill(AMBER)
156 this.wait = new Int16Array(n)
157 this.target = target
158 for (let i = 0; i < n; i += 1) this.wait[i] = stagger(i, columns)
159 }
160
161 /** A new target: only the flaps that change start falling, in a cascade. */
162 retarget(target: Cell[]): void {
163 for (let i = 0; i < target.length; i += 1) {
164 const was = this.target[i]
165 const now = target[i] as Cell
166 if (was === undefined || was.ch !== now.ch) this.wait[i] = stagger(i, this.columns)
167 }
168 this.target = target
169 }
170
171 get settled(): boolean {
172 for (let i = 0; i < this.shown.length; i += 1) {
173 if (this.shown[i] !== (this.target[i] as Cell).ch) return false
174 }
175 return true
176 }
177
178 /** One frame: every flap that is due falls by one character. */
179 step(): boolean {
180 let moving = false
181 for (let i = 0; i < this.shown.length; i += 1) {
182 const want = (this.target[i] as Cell).ch
183 const have = this.shown[i] as number
184 if (have === want) continue
185 moving = true
186 if ((this.wait[i] as number) > 0) {
187 this.wait[i] = (this.wait[i] as number) - 1
188 continue
189 }
190 this.shown[i] = nextFlap(have, want)
191 }
192 return moving
193 }
194
195 /** The Raster's cells: settled flaps in their colour, falling ones bright. */
196 encode(): string {
197 const n = this.shown.length
198 const words = new Uint32Array(n * 3)
199 for (let i = 0; i < n; i += 1) {
200 const t = this.target[i] as Cell
201 const ch = this.shown[i] as number
202 const falling = ch !== t.ch
203 words[i * 3] = ch
204 words[i * 3 + 1] = falling ? FLIPPING : t.fg
205 words[i * 3 + 2] = falling && t.bg === FLAP_BG ? 0x262626 : t.bg
206 }
207 return base64(new Uint8Array(words.buffer))
208 }
209}
210
211/** Frames a flap waits before falling: left to right, top to bottom, like a real board. */
212function stagger(i: number, columns: number): number {
213 const row = Math.floor(i / columns)
214 const col = i % columns
215 return Math.min(40, Math.floor(col * 0.35) + row * 3)
216}
217
218/** The next character on the drum towards `want`; a character off the drum snaps straight there. */
219export function nextFlap(have: number, want: number): number {
220 const from = DRUM_INDEX.get(have)
221 const to = DRUM_INDEX.get(want)
222 if (from === undefined || to === undefined) return want
223 return DRUM.charCodeAt((from + 1) % DRUM.length)
224}
225
226/** Board text for a status from a todo or task. */
227export function statusOf(status: string, hadError: boolean): BoardStatus {
228 if (status === 'completed') return 'DEPARTED'
229 if (status === 'deleted') return 'CANCELLED'
230 if (status === 'in_progress') return hadError ? 'DELAYED' : 'BOARDING'
231 return 'ON TIME'
232}
233
234/** "HH:MM" in local time. */
235export function clockTime(ms: number): string {
236 const d = new Date(ms)
237 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
238}
239
240/** What a tool call is about, for the platform column. */
241export function platformOf(e: { tool: string; [k: string]: unknown }): string {
242 const path = e.file_path ?? e.notebook_path
243 if (typeof path === 'string' && path.length > 0) return path.split('/').pop() ?? path
244 if (e.tool === 'Bash' && typeof e.command === 'string') {
245 const word = e.command.trim().split(/\s+/)[0] ?? ''
246 return word.split('/').pop() ?? 'BASH'
247 }
248 if (typeof e.url === 'string') {
249 try {
250 return new URL(e.url).hostname
251 } catch {
252 return 'WEB'
253 }
254 }
255 return e.tool.startsWith('mcp__') ? (e.tool.split('__')[1] ?? 'MCP') : e.tool
256}
257
258/** Is this row still on its way: boarding, delayed while running, or a turn that has not ended. */
259export function isLive(r: BoardRow): boolean {
260 if (r.source === 'turn') return r.isActive
261 return r.status === 'BOARDING' || r.status === 'DELAYED'
262}
263
264/** The rows a board shows, best first: what is on its way, then the most recently changed. */
265export function boardOrder(rows: BoardRow[], room: number): BoardRow[] {
266 const live = rows.filter(isLive).sort((a, b) => b.at - a.at)
267 const rest = rows.filter(r => !isLive(r)).sort((a, b) => b.at - a.at)
268 return [...live, ...rest].slice(0, Math.max(0, room))
269}
270
271// Most rows kept: turn history first, then finished work, are what make room.
272export const MAX_ROWS = 60
273export const MAX_TURNS = 12
274
275/** Bound the list without ever dropping work that is still on its way. */
276export function trim(rows: BoardRow[]): BoardRow[] {
277 let list = rows
278 const turns = list.filter(r => r.source === 'turn' && !r.isActive).sort((a, b) => a.at - b.at)
279 const extraTurns = Math.max(0, turns.length - MAX_TURNS)
280 const dropTurns = new Set(turns.slice(0, extraTurns).map(r => r.id))
281 list = list.filter(r => !dropTurns.has(r.id))
282 if (list.length <= MAX_ROWS) return list
283 const finished = list
284 .filter(r => !isLive(r) && r.status !== 'ON TIME')
285 .sort((a, b) => (a.source === 'turn' ? 0 : 1) - (b.source === 'turn' ? 0 : 1) || a.at - b.at)
286 const drop = new Set(finished.slice(0, list.length - MAX_ROWS).map(r => r.id))
287 return list.filter(r => !drop.has(r.id))
288}
289
290/** Stable ids for a todo list: the raw content, plus which copy it is when the same text repeats. */
291export function todoIds(contents: string[]): string[] {
292 const seen = new Map<string, number>()
293 return contents.map(c => {
294 const n = seen.get(c) ?? 0
295 seen.set(c, n + 1)
296 return `todo:${c}#${n}`
297 })
298}
299types/index.d.ts 29 lines1// What a row's status column says, Solari-style.
2export type BoardStatus = 'ON TIME' | 'BOARDING' | 'DEPARTED' | 'DELAYED' | 'CANCELLED'
3
4// One departure: a work item of the session.
5export type BoardRow = {
6 id: string
7 // Wall-clock time the item first appeared, "HH:MM".
8 time: string
9 destination: string
10 platform: string
11 status: BoardStatus
12 source: 'todo' | 'task' | 'turn'
13 // Sort key: when the row last changed, ms since the epoch.
14 at: number
15 // An error happened while this item was in progress (it shows DELAYED until it finishes).
16 isTroubled: boolean
17 // A turn row that is still running; finished turns are frozen.
18 isActive: boolean
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'departure-board': {
24 rows: BoardRow[]
25 isBandHidden: boolean
26 }
27 }
28}
29