A floating board of the Claude Code sessions running right now: who works, who waits for you, what was done - and a jump to the tmux window of the one you pick.

<img src="assets/logo-light.png" alt="The Claude spark looking through a telescope" width="200">
<h1 align="center">telescope-claude-code</h1>
A mod for Claude Code: a floating window with the sessions that are running right now - who works, who waits for you, what each has done - and a jump to the tmux window of the one you pick. A picker in the manner of Neovim's Telescope, for your agents.
The plugin inside is called session-board; its command is /board.
For people who run Claude Code in many tmux windows at once and lose track of which agent has finished and is waiting.
╭───────────── Active sessions ──────────────╮╭────────────────── Publish the session board ───────────────────╮
│ > search · enter: go · alt+digit: by numbe ││ ◐ working 8 min │
╰────────────────────────────────────────────╯│ ~/github/telescope-claude-code │
╭────────────────────────────────────────────╮│ tmux mods:@1.%2 │
│ ● 1 Fix the flaky checkout te… 3 min ││ │
│ ▌ ◐ 2 Publish the sessio… (this) 8 min ││ you │
│ ◐ 3 Move billing to Postgres … 42 min ││ make a repository so that other people can use this mod │
│ ○ 4 Draft the release notes 2 h ││ │
│ ○ 5 New session 6 h ││ summary │
│ ││ Translating the mod to English and writing the README before │
│ ││ the repository goes public. │
│ ││ │
│ ││ now │
│ ││ Edit │
│ ││ │
│ ││ agent │
╰────────────────────────────────────────────╯╰────────────────────────────────────────────────────────────────╯
Every live interactive session from Claude Code's own registry (~/.claude/sessions), including the ones started before the mod was installed. claude -p and SDK runs are left out.
| Mark | Meaning |
|---|---|
● | the agent stopped and waits for you, for no longer than the threshold |
◐ | the agent is working; such a session never goes dim, however long it works |
○ | no answer for longer than the threshold (10 minutes): the session went stale, its row is dim |
you), a summary written by a model (summary), the tool that is running (now) and the agent's last reply (agent). A part with nothing to show is left out.(this) marks the session the window was opened from.| What | Version | Needed for |
|---|---|---|
| Claude Code | 2.1.287 or later | everything: this is a mod, and mods are on by default from that version |
python3 in PATH | 3.8 or later | everything: it reads the sessions |
| tmux | 3.3 or later | the floating window, and going to a session |
fzf | 0.71 or later | the floating window |
Without tmux or a new enough fzf nothing breaks: the board opens as a pane inside Claude Code instead of a floating window. Mind that the fzf packaged by many distributions is older than 0.71; fzf --version tells.
Developed and tested on Linux with Claude Code 2.1.294, tmux 3.7 and fzf 0.74. macOS should work but is untested.
In a Claude Code session:
/plugin install session-board --marketplace danilpavlov/telescope-claude-code
Claude Code asks whether to add the marketplace (y), shows the plugin and asks for a scope - the user scope makes the mod load in every session - and then offers the one setting, which you can leave at its default.
Or from a shell, with no questions asked:
claude plugin install session-board --marketplace danilpavlov/telescope-claude-code
Either way the mod is active at once in the session you installed it from, and in every session started after. Sessions that were already running pick it up after /reload-plugins.
To check that it loaded, run /plugin: a dim line under the tabs names the active mods, and session-board should be among them. Then press space twice on an empty prompt.
To remove it: /plugin uninstall session-board@telescope-claude-code.
<space><space> opens a picker in Neovim. The prompt stays empty; in a prompt that holds text, spaces are typed as usual./board.The window rereads the sessions every 5 seconds while it is open.
| Key | What happens |
|---|---|
| letters and digits | typed into the search; the search is fuzzy, over the title and the project folder |
↑ ↓ | move the selection without leaving the search; the preview follows |
Enter | go to the selected session |
alt+1 ... alt+9 | go to the session under that number |
Esc | close the window and go nowhere |
Order: the waiting sessions first, then the working ones, then the stale ones; within a group the one whose state changed last is on top. While the window is open the order and the numbers stay put, even when a session changes state or the search narrows the list: a number never takes you to another session. The number of a session that ended stays unused, a new session goes last. The next opening orders everything anew.
Going to a session is tmux switch-client -t %<pane>: it changes the tmux session, the window and the pane at once. The session you are in, and a session that runs outside tmux, are not gone to: tmux says why in its status line.
One or two sentences about what the agent has done and what it waits for, written by haiku.
| Session | When it is asked about |
|---|---|
| waiting | once per stop |
| working | at once, then no sooner than every 3 minutes and only if the transcript moved |
| stale | never |
summary part; the next try is no sooner than 3 minutes later and only once the transcript has moved.The floating window is drawn by tmux and fzf. When Claude Code runs outside tmux, or fzf is missing or too old, the mod opens the same list as a Claude Code pane: two frames docked beside the conversation, or a block above the prompt.
| Focus | Key | What happens |
|---|---|---|
| search field | letters and digits | typed into the search, the list narrows |
| search field | Enter | go to the selected session, the first of the list |
| search field | ↓ or Tab | the focus moves into the list |
| a row | ↑ ↓ | the selection moves, the preview follows |
| a row | Enter or a digit 1-9 | go |
| anywhere | Esc | close the pane and go nowhere |
staleMinutes: after how many minutes of waiting a session goes dim. 10 by default. Change it in /config, in the mod's row.
The mod writes nothing into Claude Code's own files. It reads:
~/.claude/sessions/<pid>.json: the registry of live processes - session id, folder, status, tmux address;~/.claude/projects/: the title, your prompts, the agent's replies, its tool calls.It keeps two things of its own: the cache of summaries in the mod's store, and a copy of them for the floating window in $XDG_RUNTIME_DIR/session-board/summaries.json (or ~/.cache/session-board/summaries.json).
hooks/index_live.py: if Claude Code changes them, that is the one file to fix./board is unknown and /plugin does not name session-board among the active mods. The hooks module did not load. Check claude --version (2.1.287 or later). Mods are also off under --safe-mode and --bare, with disableAllHooks in your settings, and where an organization allows only its own mods. claude --debug says which, in a line that names session-board.
The board opens as a pane, not as a floating window. One of: Claude Code is not running inside tmux; fzf is missing or older than 0.71; tmux is older than 3.3. claude --debug has the reason in a line that starts with session-board: the floating window did not open.
No summary in the preview. The model did not answer, or haiku is not available to your account. The board works without summaries: the preview still has your prompt and the agent's last reply. The reason is in claude --debug, in a line that starts with session-board: no summary.
A session is missing from the list. Only interactive sessions are listed: claude -p and SDK runs are not. A session started by a much older Claude Code may not be in the registry at all.
python3 -m unittest discover -s tests -p 'test_*.py' # the indexer and the floating window
claude plugin validate .
claude plugin test . # the pure functions and the pane
npx -y -p typescript@5 tsc -p .
tsconfig.json extends the type declarations Claude Code lays into .claude-plugin/types/ when it loads the mod from a folder you own, so run the mod once before the type check: claude --plugin-dir ..
No test starts fzf or tmux: the launchers are replaced, and the real ones are disarmed in the test module.
| File | What it does |
|---|---|
hooks/index_live.py | reads the registry and the transcripts, prints the live sessions as JSON |
hooks/board_popup.py | the floating window: the tmux popup, fzf, its list and its preview |
hooks/board.ts | pure functions: states, order, layout, the summary policy |
hooks/register.tsx | the hooks module: the command, the leader, the pane, the timer, the summaries |
types/index.d.ts | the state contract |
hooks/register.tsx 640 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import {
5 PANE_COLUMNS,
6 PREVIEW_PADDING,
7 ageCell,
8 cleanSummary,
9 clip,
10 filterLive,
11 frameRows,
12 hotkeyOf,
13 isHeldBack,
14 isLeader,
15 isSummary,
16 layoutOf,
17 markOf,
18 mergeOrder,
19 needsSummary,
20 orderOf,
21 paneRows,
22 parseLive,
23 phaseOf,
24 popupArgv,
25 previewOf,
26 previewWidth,
27 rowKey,
28 selectedOf,
29 staleMinutesOf,
30 staleMsOf,
31 summariesFileOf,
32 switchArgv,
33 titleCell,
34 titleLabel,
35} from './board'
36import type { Failure, PreviewLine } from './board'
37import type { Indexed, LiveIndex, LiveSession, Phase, Summary } from '../types'
38
39const COMMAND = 'board'
40const PANE = 'board'
41const TITLE = 'Active sessions'
42// The indexer reads the registry and a megabyte of each live transcript
43const INDEXER_TIMEOUT_MS = 10_000
44// How often the list is collected again while the board is open
45const REFRESH_MS = 5_000
46// How long after the leader the board opens: the emptied prompt has to land first, since a pane
47// that asks for the keyboard over a draft is refused it
48const LEADER_DELAY_MS = 30
49const SUMMARY_MODEL = 'haiku'
50const SUMMARY_MAX_TOKENS = 200
51const SUMMARY_TIMEOUT_MS = 20_000
52// Summaries asked of the model at once; the rest wait for the next tick
53const SUMMARY_PARALLEL = 3
54const SUMMARY_SYSTEM =
55 'You write one line for a status board of coding-agent sessions. From the digest, answer in one or two ' +
56 'sentences of English, at most 200 characters: what has been done, and what is happening now or what ' +
57 'the person is waited on for. No preamble, no lists, no quotes.'
58// The mod's keys in $.store: one summary a session
59const STORE_PREFIX = 'summary:'
60const UNREADABLE = 'The indexer printed something unreadable'
61const NO_HOME = 'HOME is not set: cannot tell where the session registry is'
62// The search field's key, and what the focus ring names when it stands on it
63const SEARCH = 'query'
64// Theme keys, so the colors follow the person's theme. The pane is drawn as a picker: two
65// rounded frames with their titles on them, a selection bar, a preview beside the list
66const PHASE_COLOR: Record<Phase, string> = { waiting: 'claude', working: 'success', stale: 'inactive' }
67const FRAME_COLOR = 'promptBorder'
68const ACCENT_COLOR = 'claude'
69const SELECTION_COLOR = 'selectionBg'
70const QUIET_COLOR = 'subtle'
71const AGE_COLOR = 'inactive'
72// The preview's lines by kind; a state line takes its session's phase color instead
73const LINE_COLOR: Record<PreviewLine['kind'], string | undefined> = {
74 state: undefined,
75 directory: 'suggestion',
76 meta: QUIET_COLOR,
77 label: QUIET_COLOR,
78 text: undefined,
79 note: QUIET_COLOR,
80 gap: undefined,
81}
82// Cells from a frame's corner to its title, and what the title leaves of the frame's width:
83// the corners, a dash each side, a space each side
84const FRAME_TITLE_LEFT = 2
85const FRAME_TITLE_CHROME = 6
86// The preview's title while no session is selected
87const NO_SESSION = 'Session'
88
89const sessions = atom({ plugin: 'session-board', key: 'sessions' } as const, [])
90const order = atom({ plugin: 'session-board', key: 'order' } as const, [])
91const summaries = atom({ plugin: 'session-board', key: 'summaries' } as const, {})
92const skipped = atom({ plugin: 'session-board', key: 'skipped' } as const, 0)
93const error = atom({ plugin: 'session-board', key: 'error' } as const, null)
94const currentId = atom({ plugin: 'session-board', key: 'currentId' } as const, '')
95const home = atom({ plugin: 'session-board', key: 'home' } as const, '')
96const nowMs = atom({ plugin: 'session-board', key: 'nowMs' } as const, 0)
97const query = atom({ plugin: 'session-board', key: 'query' } as const, '')
98const focused = atom({ plugin: 'session-board', key: 'focused' } as const, '')
99
100// What no drawing reads, so a reload may lose it: the refresh timer, the collection under way,
101// whether the floating window is up, the sessions whose summary is being asked, and the
102// summaries that were not got
103let timer: Timer | undefined
104let isCollecting = false
105let isPopupOpen = false
106const asking = new Set<string>()
107const failures = new Map<string, Failure>()
108
109const firstLine = (text: string): string => text.trim().split('\n')[0] ?? ''
110
111const reasonOf = (failure: unknown): string =>
112 failure instanceof Error ? failure.message : String(failure)
113
114const withoutDigest = ({ digest, ...session }: Indexed): LiveSession => session
115
116// Claude Code's configuration folder, or '' where nothing says where it is
117const configDirOf = async ($: EngineInterface, homeDir: string): Promise<string> =>
118 (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (homeDir === '' ? '' : `${homeDir}/.claude`)
119
120// Runs the mod's indexer over the configuration folder: the live sessions, or why there are none
121const loadLive = async ($: EngineInterface, homeDir: string): Promise<LiveIndex | string> => {
122 const configDir = await configDirOf($, homeDir)
123
124 if (configDir === '') {
125 return NO_HOME
126 }
127
128 const ran = await $.process
129 .run(['python3', `${$.plugin.root}/hooks/index_live.py`, configDir], { timeoutMs: INDEXER_TIMEOUT_MS })
130 .catch((failure: unknown) => reasonOf(failure))
131
132 if (typeof ran === 'string') {
133 return `Could not start python3: ${ran}`
134 }
135
136 if (ran.exitCode !== 0) {
137 return firstLine(ran.stderr) || `The indexer exited with code ${ran.exitCode}`
138 }
139
140 if (ran.isStdoutTruncated) {
141 return UNREADABLE
142 }
143
144 try {
145 return parseLive(ran.stdout)
146 } catch {
147 return UNREADABLE
148 }
149}
150
151// The summaries kept for the live sessions. The keys of sessions that are gone are dropped
152const loadSummaries = async (
153 $: EngineInterface,
154 live: readonly LiveSession[],
155): Promise<Record<string, Summary>> => {
156 const ids = new Set(live.map(session => session.id))
157 const kept: Record<string, Summary> = {}
158
159 for (const key of await $.store.keys()) {
160 if (!key.startsWith(STORE_PREFIX)) {
161 continue
162 }
163
164 const id = key.slice(STORE_PREFIX.length)
165
166 if (!ids.has(id)) {
167 await $.store.delete(key)
168 continue
169 }
170
171 const value = await $.store.get(key)
172
173 if (isSummary(value)) {
174 kept[id] = value
175 }
176 }
177
178 return kept
179}
180
181// Leaves the summaries where the floating window reads them. The window is another program:
182// it cannot ask the mod, so the mod writes them out, whole, each time one changes
183const handSummaries = async ($: EngineInterface): Promise<string> => {
184 const file = summariesFileOf((await $.env.get('XDG_RUNTIME_DIR')) ?? '', (await $.env.get('HOME')) ?? '')
185
186 await $.fs
187 .write(file, JSON.stringify(await read($, summaries)))
188 .catch((failure: unknown) => $.ui.log(`session-board: summaries not written: ${reasonOf(failure)}`, { to: 'debug' }))
189
190 return file
191}
192
193// Asks the model about one session and keeps what it said; a failure is noted and said to the debug log
194const askSummary = async ($: EngineInterface, session: Indexed): Promise<void> => {
195 const { id, stamp, digest } = session
196
197 if (stamp === null || digest === null) {
198 return
199 }
200
201 const answer = await $.model
202 .complete({
203 model: SUMMARY_MODEL,
204 system: SUMMARY_SYSTEM,
205 prompt: digest,
206 maxTokens: SUMMARY_MAX_TOKENS,
207 timeoutMs: SUMMARY_TIMEOUT_MS,
208 })
209 .catch((failure: unknown) => reasonOf(failure))
210 const atMs = await $.clock.now()
211 const text = typeof answer !== 'string' && answer.isAnswered ? cleanSummary(answer.text) : ''
212
213 if (text === '') {
214 const reason = typeof answer === 'string' ? answer : answer.isAnswered ? 'empty-reply' : answer.reason
215 failures.set(id, { stamp, atMs })
216 $.ui.log(`session-board: no summary for session ${id}: ${reason}`, { to: 'debug' })
217
218 return
219 }
220
221 const summary: Summary = { stamp, text, atMs }
222 failures.delete(id)
223 await $.store.set(`${STORE_PREFIX}${id}`, summary)
224 await update($, summaries, all => ({ ...all, [id]: summary }))
225
226 if (isPopupOpen) {
227 await handSummaries($)
228 }
229}
230
231// Starts the summaries that are due, SUMMARY_PARALLEL at once; it does not wait for them
232const askSummaries = async (
233 $: EngineInterface,
234 live: readonly Indexed[],
235 now: number,
236 staleMs: number,
237): Promise<void> => {
238 const kept = await read($, summaries)
239
240 for (const session of live) {
241 if (asking.size >= SUMMARY_PARALLEL) {
242 return
243 }
244
245 if (
246 asking.has(session.id) ||
247 session.stamp === null ||
248 !needsSummary(session, phaseOf(session, now, staleMs), kept[session.id], now) ||
249 isHeldBack(failures.get(session.id), session.stamp, now)
250 ) {
251 continue
252 }
253
254 asking.add(session.id)
255 void askSummary($, session).finally(() => asking.delete(session.id))
256 }
257}
258
259const stopTimer = (): void => {
260 timer?.cancel()
261 timer = undefined
262}
263
264const isPaneOpen = async ($: EngineInterface): Promise<boolean> =>
265 (await $.ui.panes()).some(pane => pane.id === PANE)
266
267// One refresh: the list again, the pinned order with the new sessions, the summaries that are due.
268// It runs for as long as the board is up in either form, the floating window or the pane
269const tick = async ($: EngineInterface, staleMs: number): Promise<void> => {
270 if (!isPopupOpen && !(await isPaneOpen($))) {
271 stopTimer()
272
273 return
274 }
275
276 if (isCollecting) {
277 return
278 }
279
280 isCollecting = true
281
282 try {
283 const homeDir = (await $.env.get('HOME')) ?? ''
284 const loaded = await loadLive($, homeDir)
285
286 if (typeof loaded === 'string') {
287 $.ui.log(`session-board: list not refreshed: ${loaded}`, { to: 'debug' })
288
289 return
290 }
291
292 const now = await $.clock.now()
293 await update($, sessions, () => loaded.sessions.map(withoutDigest))
294 await update($, order, pinned => mergeOrder(pinned, loaded.sessions))
295 await update($, skipped, () => loaded.skipped)
296 await update($, home, () => homeDir)
297 await update($, nowMs, () => now)
298 await askSummaries($, loaded.sessions, now, staleMs)
299 } finally {
300 isCollecting = false
301 }
302}
303
304const startTimer = ($: EngineInterface, staleMs: number): void => {
305 timer ??= $.clock.every(REFRESH_MS, () => {
306 void tick($, staleMs)
307 })
308}
309
310// Brings the person's tmux client to the session's pane, or says in one toast why it did not
311const goTo = async ($: EngineInterface, session: LiveSession): Promise<void> => {
312 if (session.id === (await read($, currentId))) {
313 $.ui.toast('You are already in this session')
314
315 return
316 }
317
318 if (session.tmuxPane === null) {
319 $.ui.toast('This session is not in tmux: cannot go to it')
320
321 return
322 }
323
324 if (((await $.env.get('TMUX')) ?? '') === '') {
325 $.ui.toast(`Not in tmux. The session is at ${session.tmuxTarget ?? session.tmuxPane}`)
326
327 return
328 }
329
330 const ran = await $.process
331 .run(switchArgv(session.tmuxPane))
332 .catch((failure: unknown) => reasonOf(failure))
333
334 if (typeof ran === 'string') {
335 $.ui.toast(`tmux: ${ran}`)
336
337 return
338 }
339
340 if (ran.exitCode !== 0) {
341 $.ui.toast(`tmux: ${firstLine(ran.stderr) || `code ${ran.exitCode}`}`)
342
343 return
344 }
345
346 stopTimer()
347 await $.ui.close({ id: PANE })
348}
349
350// The pane: the board inside Claude Code, where the floating window cannot open
351const showPane = async ($: EngineInterface, count: number): Promise<void> => {
352 await $.ui.open({
353 id: PANE,
354 title: TITLE,
355 focus: true,
356 closeOnEscape: true,
357 columns: PANE_COLUMNS,
358 rows: paneRows(count),
359 })
360}
361
362// The floating window: a tmux popup with the same list and preview. Resolves once it has closed:
363// true when it was up, false when it could not open, the reason said to the debug log
364const showPopup = async (
365 $: EngineInterface,
366 configDir: string,
367 current: string,
368 staleMinutes: number,
369): Promise<boolean> => {
370 isPopupOpen = true
371
372 try {
373 const file = await handSummaries($)
374 const pieces = $.process
375 .spawn({ argv: popupArgv($.plugin.root, configDir, file, current, staleMinutes) })
376 [Symbol.asyncIterator]()
377 let said = ''
378 let step = await pieces.next()
379
380 while (!step.done) {
381 said += step.value.stream === 'stderr' ? step.value.text : ''
382 step = await pieces.next()
383 }
384
385 if (step.value.code === 0) {
386 return true
387 }
388
389 $.ui.log(`session-board: the floating window did not open: ${firstLine(said) || `code ${step.value.code}`}`, {
390 to: 'debug',
391 })
392
393 return false
394 } catch (failure) {
395 $.ui.log(`session-board: the floating window did not open: ${reasonOf(failure)}`, { to: 'debug' })
396
397 return false
398 } finally {
399 isPopupOpen = false
400 }
401}
402
403// Collects the sessions and shows the board: the floating window inside tmux, else the pane.
404// Resolves with how many sessions there are, or with nothing when they could not be collected
405const openBoard = async (
406 $: EngineInterface,
407 staleMs: number,
408 staleMinutes: number,
409): Promise<number | undefined> => {
410 const homeDir = (await $.env.get('HOME')) ?? ''
411 const loaded = await loadLive($, homeDir)
412 const now = await $.clock.now()
413 const id = await $.session.id()
414 const isFailed = typeof loaded === 'string'
415 const live = isFailed ? [] : loaded.sessions
416 const kept = isFailed ? {} : await loadSummaries($, live)
417
418 await update($, sessions, () => live.map(withoutDigest))
419 // Every opening pins the order anew
420 await update($, order, () => orderOf(live, now, staleMs))
421 await update($, summaries, () => kept)
422 await update($, skipped, () => (isFailed ? 0 : loaded.skipped))
423 await update($, error, () => (isFailed ? loaded : null))
424 await update($, currentId, () => id)
425 await update($, home, () => homeDir)
426 await update($, nowMs, () => now)
427 // Every opening starts from the whole list, the selection on its first row
428 await update($, query, () => '')
429 await update($, focused, () => '')
430
431 // A failure is said once, in the pane
432 if (isFailed) {
433 stopTimer()
434 await showPane($, 0)
435
436 return undefined
437 }
438
439 startTimer($, staleMs)
440 await askSummaries($, live, now, staleMs)
441
442 if (((await $.env.get('TMUX')) ?? '') === '') {
443 await showPane($, live.length)
444
445 return live.length
446 }
447
448 // The window stays up for as long as the person looks at it: nothing waits for it here.
449 // Once it has closed, the next tick finds nothing to refresh for and stops the timer
450 void showPopup($, await configDirOf($, homeDir), id, staleMinutes).then(async wasUp => {
451 if (!wasUp) {
452 await showPane($, live.length)
453 }
454 })
455
456 return live.length
457}
458
459export const register: Register = (on, options) => {
460 const staleMs = staleMsOf(options.staleMinutes)
461 const staleMinutes = staleMinutesOf(options.staleMinutes)
462
463 on('session.start', async ($, e, next) => {
464 await $.command.register({
465 name: COMMAND,
466 description: 'Show the running sessions and go to the tmux window of the chosen one',
467 })
468 const started = await next(e)
469
470 // The module was reloaded under an open pane: its timer went with the old copy
471 if (await isPaneOpen($)) {
472 startTimer($, staleMs)
473 }
474
475 return started
476 })
477
478 on('command.run', { command: COMMAND }, async $ => {
479 const count = await openBoard($, staleMs, staleMinutes)
480
481 return count === undefined ? {} : { text: `Active sessions: ${count}` }
482 })
483
484 // The leader: two spaces on an empty prompt open the board, and the prompt stays empty
485 on('prompt.edit', async ($, e, next) => {
486 if (!isLeader(e.text, e.inputText)) {
487 return next(e)
488 }
489
490 $.clock.after(LEADER_DELAY_MS, () => {
491 void openBoard($, staleMs, staleMinutes)
492 })
493
494 return { text: '', cursor: 0 }
495 })
496
497 // The preview follows the pane's focus ring: the row it stands on is the session shown
498 on('ui.focus', async ($, e, next) => {
499 const moved = await next(e)
500
501 if (e.component === 'Pane' && e.requestId === PANE && moved.deny === undefined) {
502 await update($, focused, () => e.element ?? '')
503 }
504
505 return moved
506 })
507
508 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
509 // The mobile app draws no text field
510 if (e.surface === 'mobile') {
511 return next(e)
512 }
513
514 const { Box, Button, Input, Text } = $.ui.resolve(e)
515 const failed = await read($, error)
516
517 if (failed !== null) {
518 return <Text>{failed}</Text>
519 }
520
521 const all = await read($, sessions)
522
523 if (all.length === 0) {
524 return <Text>No active sessions</Text>
525 }
526
527 const pinned = await read($, order)
528 const kept = await read($, summaries)
529 const homeDir = await read($, home)
530 const now = await read($, nowMs)
531 const current = await read($, currentId)
532 const dropped = await read($, skipped)
533 const asked = await read($, query)
534 const ring = await read($, focused)
535 const found = new Set(filterLive(all, asked, homeDir).map(session => session.id))
536 const byId = new Map(all.map(session => [session.id, session]))
537 // A session that is gone keeps its place in the order and is not drawn; neither is one the
538 // search does not let through, and its digit stays its own
539 const rows = pinned.flatMap((id, place) => {
540 const session = byId.get(id)
541
542 return session === undefined || !found.has(id) ? [] : [{ session, place }]
543 })
544 const selected = selectedOf(
545 rows.map(row => row.session),
546 ring,
547 )
548 const layout = layoutOf(e.props.bodyColumns)
549 const counter = `${rows.length}/${all.length}`
550 const lines =
551 selected === undefined
552 ? [{ kind: 'note' as const, text: 'Nothing selected' }]
553 : previewOf(
554 selected,
555 phaseOf(selected, now, staleMs),
556 kept[selected.id],
557 now,
558 homeDir,
559 previewWidth(layout.preview),
560 )
561 const selectedColor = selected === undefined ? undefined : PHASE_COLOR[phaseOf(selected, now, staleMs)]
562 // A frame's title is drawn over its top border: an absolute Box after the frame, so it paints last
563 const frameTitle = (title: string, width: number) => (
564 <Box position="absolute" top={0} left={FRAME_TITLE_LEFT}>
565 <Text color={ACCENT_COLOR} bold>{` ${clip(title, Math.max(1, width - FRAME_TITLE_CHROME))} `}</Text>
566 </Box>
567 )
568
569 return (
570 <Box
571 flexDirection={layout.isStacked ? 'column' : 'row'}
572 minHeight={layout.isStacked ? undefined : frameRows(e.props.scroll.bodyRows)}
573 >
574 <Box flexDirection="column" width={layout.list}>
575 <Box borderStyle="round" borderColor={FRAME_COLOR} width={layout.list} flexDirection="column" flexGrow={1}>
576 <Box>
577 <Text color={ACCENT_COLOR}>{' > '}</Text>
578 <Box flexGrow={1}>
579 <Input
580 key={SEARCH}
581 value={asked}
582 placeholder="search"
583 submitLabel="go"
584 autoFocus
585 onInput={value => update($, query, () => value)}
586 onSubmit={() => (selected === undefined ? undefined : goTo($, selected))}
587 />
588 </Box>
589 <Text color={QUIET_COLOR}>{dropped > 0 ? `${counter} · skipped ${dropped} ` : `${counter} `}</Text>
590 </Box>
591 <Text color={FRAME_COLOR}>{'─'.repeat(Math.max(1, layout.list - 2))}</Text>
592 {rows.length === 0 && <Text color={QUIET_COLOR}>{' Nothing found'}</Text>}
593 {rows.map(({ session, place }) => {
594 const phase = phaseOf(session, now, staleMs)
595 const isSelected = session.id === selected?.id
596 const hotkey = hotkeyOf(place)
597
598 return (
599 <Box backgroundColor={isSelected ? SELECTION_COLOR : undefined}>
600 <Text color={ACCENT_COLOR}>{isSelected ? '▌' : ' '}</Text>
601 <Text color={PHASE_COLOR[phase]}>{`${markOf(phase)} `}</Text>
602 <Box flexGrow={1}>
603 <Button
604 key={rowKey(session.id)}
605 label={titleLabel(session.title, titleCell(layout.list), hotkey, session.id === current)}
606 hotkey={hotkey}
607 plain
608 dimColor={phase === 'stale'}
609 onPress={() => goTo($, session)}
610 />
611 </Box>
612 <Text color={AGE_COLOR}>{ageCell(now - session.statusSinceMs)}</Text>
613 </Box>
614 )
615 })}
616 </Box>
617 {frameTitle(TITLE, layout.list)}
618 </Box>
619 <Box flexDirection="column" width={layout.preview}>
620 <Box
621 borderStyle="round"
622 borderColor={FRAME_COLOR}
623 width={layout.preview}
624 flexDirection="column"
625 flexGrow={1}
626 paddingX={PREVIEW_PADDING}
627 >
628 {lines.map(line => (
629 <Text color={line.kind === 'state' ? selectedColor : LINE_COLOR[line.kind]} bold={line.kind === 'state'}>
630 {line.kind === 'gap' ? ' ' : line.text}
631 </Text>
632 ))}
633 </Box>
634 {frameTitle(selected?.title ?? NO_SESSION, layout.preview)}
635 </Box>
636 </Box>
637 )
638 })
639}
640hooks/board.ts 385 lines1import type { Indexed, LiveIndex, LiveSession, Phase, Summary } from '../types'
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6// How long a session waits before it goes dim, where the setting says nothing usable
7const DEFAULT_STALE_MINUTES = 10
8// A working session's summary is asked again no sooner than this, and so is one that failed
9export const SUMMARY_REFRESH_MS = 3 * MINUTE
10const SUMMARY_MAX = 200
11// The status of a session whose agent is working
12const BUSY = 'busy'
13// The status of a session that waits for the person in the usual way
14const IDLE = 'idle'
15// Columns the pane asks its dock for: a list of 50 and a preview of 60
16export const PANE_COLUMNS = 110
17// A pane narrower than this puts the preview under the list
18const STACK_UNDER = 72
19// The list's share of a pane that holds both side by side, and its bounds
20const LIST_SHARE = 0.45
21const LIST_MIN = 40
22const LIST_MAX = 54
23// Cells a frame takes from its width: the two borders
24const BORDERS = 2
25// Cells of a row before its title: the selection bar, the mark, a space
26const ROW_LEAD = 3
27// Cells of a row's age: the label set right in 7, and a space before the border
28const AGE_WIDTH = 7
29const AGE_CELL = AGE_WIDTH + 1
30// Cells the preview's text stands in from its frame, each side
31export const PREVIEW_PADDING = 1
32// Rows of the list's frame that are not sessions: two borders, the search field, the rule
33const LIST_CHROME_ROWS = 4
34// Rows the pane asks for where it is placed above the prompt: a preview with a prompt, a summary and a reply
35const PANE_ROWS_MIN = 18
36// Lines of the preview a prompt, a summary and a reply may take
37const PROMPT_LINES = 3
38const SUMMARY_LINES = 5
39const REPLY_LINES = 4
40// Lines every preview starts with: the state, the directory, the tmux address
41const HEAD_LINES = 3
42// Lines a section of the preview takes before its text: a gap and its label
43const SECTION_LEAD = 2
44// Rows of a frame that holds the fullest preview: a prompt, a summary, a running tool and a reply
45const FRAME_ROWS =
46 BORDERS +
47 HEAD_LINES +
48 (SECTION_LEAD + PROMPT_LINES) +
49 (SECTION_LEAD + SUMMARY_LINES) +
50 (SECTION_LEAD + 1) +
51 (SECTION_LEAD + REPLY_LINES)
52// Places that get a digit
53const HOTKEYS = 9
54// Cells a plain Button draws before its label: the digit, a colon, a space
55const HOTKEY_WIDTH = 3
56// What follows the title of the session the pane is open in
57const HERE = ' (this)'
58
59// One line of the preview, and what it is: the render colors it by kind
60export type PreviewLine = {
61 kind: 'state' | 'directory' | 'meta' | 'label' | 'text' | 'note' | 'gap'
62 text: string
63}
64// How the pane splits its width: the list's frame, the preview's, and whether the preview is under the list
65export type Layout = { isStacked: boolean; list: number; preview: number }
66// A summary that was not got: the stamp it was asked at, and when
67export type Failure = { stamp: string; atMs: number }
68
69// Reads what the indexer printed; throws when it is not an index
70export const parseLive = (stdout: string): LiveIndex => {
71 const parsed: unknown = JSON.parse(stdout)
72
73 if (typeof parsed !== 'object' || parsed === null) {
74 throw new Error('not an index')
75 }
76
77 const fields = parsed as Record<string, unknown>
78
79 if (!Array.isArray(fields.sessions)) {
80 throw new Error('no sessions list')
81 }
82
83 return {
84 sessions: fields.sessions as Indexed[],
85 skipped: typeof fields.skipped === 'number' ? fields.skipped : 0,
86 }
87}
88
89// The setting as minutes; one that is no number of minutes from 1 up is ten minutes
90export const staleMinutesOf = (staleMinutes: unknown): number =>
91 typeof staleMinutes === 'number' && Number.isFinite(staleMinutes) && staleMinutes >= 1
92 ? staleMinutes
93 : DEFAULT_STALE_MINUTES
94
95// The same in milliseconds
96export const staleMsOf = (staleMinutes: unknown): number => staleMinutesOf(staleMinutes) * MINUTE
97
98// Two spaces typed into an empty prompt: the leader that opens the board, as <space><space>
99// opens a picker in nvim. `draft` is the prompt before the edit, `typed` what the edit puts in:
100// the second space after the first, or both at once where the editor folded them into one edit
101export const isLeader = (draft: string, typed: string): boolean =>
102 (draft === ' ' && typed === ' ') || (draft === '' && typed === ' ')
103
104// Where the summaries are left for the floating window to read: the person's runtime folder,
105// else their cache
106export const summariesFileOf = (runtimeDir: string, home: string): string =>
107 runtimeDir !== ''
108 ? `${runtimeDir}/session-board/summaries.json`
109 : `${home}/.cache/session-board/summaries.json`
110
111// The floating window: the mod's own program, which opens a tmux popup and runs fzf in it
112export const popupArgv = (
113 root: string,
114 configDir: string,
115 summariesFile: string,
116 current: string,
117 staleMinutes: number,
118): string[] => [
119 'python3',
120 `${root}/hooks/board_popup.py`,
121 'run',
122 '--config',
123 configDir,
124 '--summaries',
125 summariesFile,
126 '--current',
127 current,
128 '--stale-minutes',
129 String(staleMinutes),
130]
131
132// A busy session works. Any other waits for the person, and past the threshold it is stale
133export const phaseOf = (session: LiveSession, nowMs: number, staleMs: number): Phase => {
134 if (session.status === BUSY) {
135 return 'working'
136 }
137
138 return nowMs - session.statusSinceMs > staleMs ? 'stale' : 'waiting'
139}
140
141export const ageLabel = (elapsedMs: number): string => {
142 if (elapsedMs < MINUTE) {
143 return '<1 min'
144 }
145
146 if (elapsedMs < HOUR) {
147 return `${Math.floor(elapsedMs / MINUTE)} min`
148 }
149
150 return elapsedMs < DAY ? `${Math.floor(elapsedMs / HOUR)} h` : `${Math.floor(elapsedMs / DAY)} d`
151}
152
153// What the session does and for how long. A status this mod does not know is shown as the word itself
154export const stateLabel = (session: LiveSession, phase: Phase, nowMs: number): string => {
155 const age = ageLabel(nowMs - session.statusSinceMs)
156
157 if (phase === 'working') {
158 return `working ${age}`
159 }
160
161 if (session.status !== IDLE) {
162 return `${session.status} ${age}`
163 }
164
165 return phase === 'waiting' ? `waiting for you ${age}` : `waiting ${age}`
166}
167
168const RANK: Record<Phase, number> = { waiting: 0, working: 1, stale: 2 }
169
170// The ids as the pane first shows them: the waiting, the working, the stale, the latest change on top
171export const orderOf = (sessions: readonly LiveSession[], nowMs: number, staleMs: number): string[] =>
172 [...sessions]
173 .sort(
174 (a, b) =>
175 RANK[phaseOf(a, nowMs, staleMs)] - RANK[phaseOf(b, nowMs, staleMs)] ||
176 b.statusSinceMs - a.statusSinceMs ||
177 a.pid - b.pid,
178 )
179 .map(session => session.id)
180
181// The pinned order with the new sessions at its end. A session that is gone keeps its place,
182// so no other session moves under a digit the person is about to press
183export const mergeOrder = (pinned: readonly string[], sessions: readonly LiveSession[]): string[] => [
184 ...pinned,
185 ...sessions.map(session => session.id).filter(id => !pinned.includes(id)),
186]
187
188export const hotkeyOf = (place: number): string | undefined =>
189 place < HOTKEYS ? String(place + 1) : undefined
190
191// Whether the model is asked about the session now. A waiting one is asked once per stop,
192// a working one at once and then no sooner than SUMMARY_REFRESH_MS, a stale one never
193export const needsSummary = (
194 session: Indexed,
195 phase: Phase,
196 cached: Summary | undefined,
197 nowMs: number,
198): boolean => {
199 if (phase === 'stale' || session.stamp === null || session.digest === null) {
200 return false
201 }
202
203 if (cached === undefined) {
204 return true
205 }
206
207 if (cached.stamp === session.stamp) {
208 return false
209 }
210
211 return phase === 'waiting' || nowMs - cached.atMs >= SUMMARY_REFRESH_MS
212}
213
214// After a failure a session is asked again only at a new stamp and SUMMARY_REFRESH_MS later:
215// a working session's stamp moves every few seconds, and the failure would repeat at each
216export const isHeldBack = (failure: Failure | undefined, stamp: string, nowMs: number): boolean =>
217 failure !== undefined && (failure.stamp === stamp || nowMs - failure.atMs < SUMMARY_REFRESH_MS)
218
219export const isSummary = (value: unknown): value is Summary => {
220 if (typeof value !== 'object' || value === null) {
221 return false
222 }
223
224 const fields = value as Record<string, unknown>
225
226 return typeof fields.stamp === 'string' && typeof fields.text === 'string' && typeof fields.atMs === 'number'
227}
228
229// Text cut at its end to a width, the cut marked
230export const clip = (text: string, width: number): string =>
231 text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
232
233// Text cut at its start to a width: a path keeps its last components
234export const clipStart = (text: string, width: number): string =>
235 text.length <= width ? text : `…${width > 1 ? text.slice(1 - width) : ''}`
236
237// The title as its Button draws it: cut so the digit before it and the mark of the current session fit
238export const titleLabel = (
239 title: string,
240 width: number,
241 hotkey: string | undefined,
242 isCurrent: boolean,
243): string => {
244 const room = width - (hotkey === undefined ? 0 : HOTKEY_WIDTH) - (isCurrent ? HERE.length : 0)
245
246 return `${clip(title, Math.max(1, room))}${isCurrent ? HERE : ''}`
247}
248
249// The model's reply as the pane draws it: one line, cut
250export const cleanSummary = (text: string): string => clip(text.split(/\s+/).filter(Boolean).join(' '), SUMMARY_MAX)
251
252const clamp = (value: number, low: number, high: number): number => Math.min(high, Math.max(low, value))
253
254const MARK: Record<Phase, string> = { waiting: '●', working: '◐', stale: '○' }
255
256export const markOf = (phase: Phase): string => MARK[phase]
257
258// The key of a session's row: its Button's, and what the focus ring names when it stands on it
259export const rowKey = (id: string): string => `go-${id}`
260
261// How a pane of a width holds the two frames: side by side, or the preview under the list
262export const layoutOf = (width: number): Layout => {
263 if (width < STACK_UNDER) {
264 return { isStacked: true, list: width, preview: width }
265 }
266
267 const list = clamp(Math.round(width * LIST_SHARE), LIST_MIN, LIST_MAX)
268
269 return { isStacked: false, list, preview: width - list }
270}
271
272// Cells of a row's title in a list frame of a width
273export const titleCell = (listWidth: number): number => Math.max(1, listWidth - BORDERS - ROW_LEAD - AGE_CELL)
274
275// Cells of the preview's text in a frame of a width
276export const previewWidth = (frame: number): number => Math.max(1, frame - BORDERS - 2 * PREVIEW_PADDING)
277
278// A row's age, set right so the ages stand in a column
279export const ageCell = (elapsedMs: number): string => `${ageLabel(elapsedMs).padStart(AGE_WIDTH)} `
280
281// Rows the two frames stand at beside each other, so they do not change height as the selection
282// moves between a session with much to show and one with little: the fullest preview's, or the pane's
283export const frameRows = (bodyRows: number): number => (bodyRows > 0 ? Math.min(bodyRows, FRAME_ROWS) : FRAME_ROWS)
284
285// Rows the pane asks for where it is placed above the prompt
286export const paneRows = (sessions: number): number => Math.max(PANE_ROWS_MIN, sessions + LIST_CHROME_ROWS)
287
288// The sessions the search field lets through: every word of the query is somewhere in the title,
289// the directory or the last prompt, as text and whatever its case
290export const filterLive = (sessions: readonly LiveSession[], query: string, home: string): LiveSession[] => {
291 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
292
293 return sessions.filter(session => {
294 const text = `${session.title} ${directoryOf(session.cwd, home)} ${session.lastPrompt ?? ''}`.toLowerCase()
295
296 return words.every(word => text.includes(word))
297 })
298}
299
300// The session the preview shows and Enter in the search field goes to: the row that holds the
301// focus ring, else the first one shown
302export const selectedOf = (shown: readonly LiveSession[], focused: string): LiveSession | undefined =>
303 shown.find(session => rowKey(session.id) === focused) ?? shown[0]
304
305// The preview of a session, line by line: what it does, where it is, what was asked, what was done
306export const previewOf = (
307 session: LiveSession,
308 phase: Phase,
309 summary: Summary | undefined,
310 nowMs: number,
311 home: string,
312 width: number,
313): PreviewLine[] => {
314 const lines: PreviewLine[] = [
315 { kind: 'state', text: clip(`${markOf(phase)} ${stateLabel(session, phase, nowMs)}`, width) },
316 { kind: 'directory', text: clipStart(directoryOf(session.cwd, home), width) },
317 { kind: 'meta', text: clip(session.tmuxTarget === null ? 'not in tmux' : `tmux ${session.tmuxTarget}`, width) },
318 ]
319 const section = (label: string, text: readonly string[]): void => {
320 lines.push({ kind: 'gap', text: '' }, { kind: 'label', text: label })
321 lines.push(...text.map(line => ({ kind: 'text' as const, text: line })))
322 }
323
324 if (session.lastPrompt !== null) {
325 section('you', wrap(session.lastPrompt, width, PROMPT_LINES))
326 }
327
328 if (summary !== undefined) {
329 section('summary', wrap(summary.text, width, SUMMARY_LINES))
330 }
331
332 if (phase === 'working' && session.activity !== null) {
333 section('now', [clip(session.activity, width)])
334 }
335
336 if (session.lastReply !== null) {
337 section('agent', wrap(session.lastReply, width, REPLY_LINES))
338 }
339
340 // Only the lines every session has: nothing was asked in it yet
341 if (lines.length === HEAD_LINES) {
342 lines.push({ kind: 'gap', text: '' }, { kind: 'note', text: 'Nothing has been asked in this session yet' })
343 }
344
345 return lines
346}
347
348// The session's directory as a person writes it: from `~` inside the home directory
349export const directoryOf = (cwd: string, home: string): string => {
350 if (home !== '' && cwd === home) {
351 return '~'
352 }
353
354 return home !== '' && cwd.startsWith(`${home}/`) ? `~${cwd.slice(home.length)}` : cwd
355}
356
357// Text broken at spaces into lines of a width; what is past the last line is cut
358export const wrap = (text: string, width: number, maxLines: number): string[] => {
359 const room = Math.max(1, width)
360 const lines: string[] = []
361 let rest = text.trim()
362
363 while (rest !== '' && lines.length < maxLines) {
364 if (rest.length <= room) {
365 lines.push(rest)
366 break
367 }
368
369 if (lines.length === maxLines - 1) {
370 lines.push(clip(rest, room))
371 break
372 }
373
374 const space = rest.lastIndexOf(' ', room)
375 const at = space > 0 ? space : room
376 lines.push(rest.slice(0, at))
377 rest = rest.slice(at).trimStart()
378 }
379
380 return lines
381}
382
383// tmux brings the person's client to the session's pane: its session, its window, the pane
384export const switchArgv = (pane: string): string[] => ['tmux', 'switch-client', '-t', pane]
385types/index.d.ts 51 lines1// One running session, as hooks/index_live.py prints it, less the digest
2export type LiveSession = {
3 id: string
4 pid: number
5 title: string
6 cwd: string
7 // `busy` while the agent works; `idle`, or a word this mod does not know, while it waits
8 status: string
9 // When the session took that status
10 statusSinceMs: number
11 lastPrompt: string | null
12 lastReply: string | null
13 // The tool still running, by name
14 activity: string | null
15 // The tmux pane the session runs in (`%4`), and the registry's whole address of it
16 tmuxPane: string | null
17 tmuxTarget: string | null
18 // Where the transcript stands: the uuid of its last user or assistant record
19 stamp: string | null
20}
21
22// The same with the text a summary is asked over
23export type Indexed = LiveSession & { digest: string | null }
24
25export type LiveIndex = { sessions: Indexed[]; skipped: number }
26
27// Waiting for the person, working, or waiting longer than the threshold
28export type Phase = 'waiting' | 'working' | 'stale'
29
30// What the model said of a session, and the transcript position it said it at
31export type Summary = { stamp: string; text: string; atMs: number }
32
33declare module 'claude-code' {
34 interface PluginState {
35 'session-board': {
36 sessions: LiveSession[]
37 order: string[]
38 summaries: Record<string, Summary>
39 skipped: number
40 error: string | null
41 currentId: string
42 home: string
43 nowMs: number
44 // What the search field holds
45 query: string
46 // The key of the element that holds the pane's focus ring: the field, a row, or nothing
47 focused: string
48 }
49 }
50}
51