claude-inbox's sections in a pane beside the transcript, with its keys.

An inbox for Claude Code's background sessions. It lists what claude agents lists and adds snooze, settle, pinning and each session's pull request.
Inspired by T3 Code's functionality and inbox-style UX, I wanted a better way to manage my Claude agents, so I built this.

Requires Ruby 3.2+ and claude on PATH.
gem install claude-inbox
claude-inbox
Put arguments you want on every launch in ~/.config/claude-inbox/config.
To add the pane to every Claude Code session:
claude-inbox install
claude-inbox install --force also replaces any other copy of the pane.
The same inbox as a pane inside Claude Code, docked beside the transcript in a fullscreen terminal.

/inbox opens it, ctrl+x tab focuses it and esc leaves. The keys are the ones below, with ge for G, n p for Tab Shift+Tab, and no a, P, X, Ctrl-x, / or R. Enter, or a click on a row, switches to the session when you attached through claude-inbox.
It opens when a session starts. To stop that, set pluginConfigs.inbox-pane.options.openOnStart to false in ~/.claude/settings.json.
t.x, or every pull request is merged or closed. Collapsed.Under each row is what the session is waiting on, what it produced, or its status line. A row with a pull request shows it as #885 open, #885 merged and so on. A steady ◌ with idle · 1 shell means the agent stopped and is waiting on a shell or sub-agent it started.
Vim bindings. Arrows and the mouse work too.
| Key | Action |
|---|---|
j k | move down / up |
gg G | first / last row |
Ctrl-d Ctrl-u | half page down / up (Ctrl-f Ctrl-b full page) |
Enter l | attach (← or Ctrl+Z returns here), adopt a remote session, or expand Snoozed / Settled |
Tab Shift+Tab | next / previous section |
h | close the peek pane, else collapse the current fold |
za zo zc | toggle / open / close the fold under the cursor |
p | toggle the read-only peek pane |
J K (Ctrl-e Ctrl-y) | scroll the peek pane |
n | new session |
N | pair a phone |
t | pin / unpin |
s | snooze: 1 15m · 2 1h · 3 tomorrow 9am · 4 until woken |
u | wake a snoozed session, or bring back a settled one |
x | settle by hand |
a | set a local alias |
o | open the pull request in the browser |
w | open the session at claude.ai/code |
P | link a pull request by hand (empty clears) |
X | stop the session; Enter resumes it later |
Ctrl-x | delete the session, transcript and worktree for good |
/ | filter; Enter keeps it, Esc clears |
R | poll now |
q | quit |
X and Ctrl-x both ask for y first.
n opens a form for the prompt, name, directory, model, effort, permissions, worktree and Remote Control. Tab moves between fields, Ctrl-S starts the session and Ctrl-O starts it and attaches.
Paste an image on macOS, or drop a file into the prompt, and refer to it as [Image #1]. Type / for a menu of your skills.
Type in Directory to pick from recent and trusted directories; Ctrl-U lists them all.
A snoozed session wakes when its timer runs out, when you press u, or when it becomes blocked or failed.
Attaching to a needs-you session moves it to Active until its state changes again.
A session settles when you press x or when all its pull requests are merged or closed. A session without a pull request only settles by hand.
With --reap, the inbox deletes background sessions that have been idle for 14 days, with claude rm. Working, pinned and snoozed sessions are kept, and claude rm refuses a worktree with unpushed commits. Each deletion is logged to ~/.config/claude-inbox/reaped.log.
Sessions with Remote Control show ⇅, and w opens them at claude.ai/code. A session started from your phone through claude remote-control can't be attached directly, so Enter offers to adopt it as a background session.
With --listen-lan, a phone on your network can start sessions.

claude-inbox --listen-lan.N, then c to copy the pairing URL.LAN mode is plain HTTP. Use it only on a network you control, and press r in the N dialog if the URL leaks. docs/remote-start.md covers ssh, the JSON API and an iOS Shortcut.
Pull requests show up when a session's transcript links to them. Install gh to keep their state current. Use P to link one by hand.
The header can show your 5-hour and 7-day rate limit usage:
session ██░░░░░░░░ 24% · 3h left week ████░░░░░░ 41% · 2d left
This needs a Pro or Max account. Add these lines to your status line script, just after it reads stdin. Run /statusline first if you don't have one.
input=$(cat)
limits=$(echo "$input" | jq -c '.rate_limits // empty')
[ -n "$limits" ] && echo "$limits" > ~/.claude/rate_limits.json.tmp && mv ~/.claude/rate_limits.json.tmp ~/.claude/rate_limits.json
See CONTRIBUTING.md.
hooks/register.tsx 502 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Folds, InboxRow, Pending, Section } from '../types'
5
6const PANE = 'inbox'
7const OPEN = { id: PANE, title: 'Inbox', columns: 48 } as const
8const POLL_MS = 500
9// Older than this and the writer has quit: it rewrites every 5 s even when nothing changed.
10const STALE_S = 30
11// How long a headless start gets to produce a fresh snapshot before another is tried.
12const RESTART_S = 60
13// A chord's first key waits this long for its second.
14const CHORD_MS = 5000
15// How long an attached inbox gets to take a switch request before the pane gives up on it.
16const SWITCH_MS = 3000
17// The gem's files, under HOME. The snapshot is read; the actions directory and switch.json
18// take the pane's requests, which the gem applies: nothing here edits the gem's own state.
19const SNAPSHOT = '.config/claude-inbox/snapshot.json'
20const ACTIONS = '.config/claude-inbox/actions'
21const SWITCH = '.config/claude-inbox/switch.json'
22// The sessions to switch to; the one this pane sits in is lifted out into `current`.
23const rows = atom({ plugin: 'inbox-pane', key: 'rows' } as const, [])
24const current = atom({ plugin: 'inbox-pane', key: 'current' } as const, null)
25const writtenAt = atom({ plugin: 'inbox-pane', key: 'writtenAt' } as const, 0)
26const cursor = atom({ plugin: 'inbox-pane', key: 'cursor' } as const, 0)
27const folds = atom({ plugin: 'inbox-pane', key: 'folds' } as const, { snoozed: true, settled: true })
28const pending = atom({ plugin: 'inbox-pane', key: 'pending' } as const, null)
29
30export type SnapshotRow = {
31 id?: string
32 session?: string
33 label?: string
34 state?: string
35 actionable?: boolean
36 waiting?: boolean
37 line?: string
38 wake_at?: number | 'until_woken'
39 remote?: string
40 pr?: { short?: string; state?: string; url?: string }
41}
42type Snapshot = { written_at?: number; sections?: Partial<Record<Section, SnapshotRow[]>> }
43// This session, as the gem's files name it, and the headless inbox this module may start.
44type Host = { session: string; command: string; headlessStartedAt: number; isHeadlessRunning: boolean }
45type Fold = keyof Folds
46type Item = { kind: 'row'; row: InboxRow } | { kind: 'fold'; section: Fold; count: number }
47type Snooze = 'm15' | 'h1' | 'tomorrow_9am' | 'until_woken'
48type Action = { action: 'settle' | 'wake' | 'pin'; id: string } | { action: 'snooze'; id: string; choice: Snooze }
49
50const ORDER: Section[] = ['pinned', 'needs_you', 'active', 'snoozed', 'settled']
51const TITLE: Record<Section, string> = { pinned: 'Pinned', needs_you: 'Needs you', active: 'Active', snoozed: 'Snoozed', settled: 'Settled' }
52const GLYPH: Record<string, string> = { blocked: '●', failed: '✗', working: '◐', idle: '◌', done: '✓', stopped: '✓' }
53const COLOR: Record<string, string> = { blocked: 'red', failed: 'red', working: 'green', idle: 'cyan' }
54const PR_COLOR: Record<string, string> = { open: 'green', draft: 'yellow', merged: 'magenta', closed: 'red' }
55const SNOOZE: Record<string, { choice: Snooze; label: string }> = {
56 '1': { choice: 'm15', label: '15m' },
57 '2': { choice: 'h1', label: '1h' },
58 '3': { choice: 'tomorrow_9am', label: 'tomorrow 9am' },
59 '4': { choice: 'until_woken', label: 'until woken' },
60}
61// What each chord's second key does, drawn under the footer while it waits.
62const MENU: Record<NonNullable<Pending>, [string, string][]> = {
63 g: [
64 ['g', 'first'],
65 ['e', 'last'],
66 ],
67 z: [
68 ['a', 'toggle the fold'],
69 ['o', 'open it'],
70 ['c', 'close it'],
71 ],
72 s: [
73 ['1', '15 minutes'],
74 ['2', '1 hour'],
75 ['3', 'tomorrow 9am'],
76 ['4', 'until woken'],
77 ],
78}
79
80function isFold(section: Section): section is Fold {
81 return section === 'snoozed' || section === 'settled'
82}
83
84// The Button drawn for an item, its label: the ring walks these with the arrows, a click presses one.
85function itemKey(item: Item): string {
86 return item.kind === 'fold' ? `fold:${item.section}` : `row:${item.row.id ?? item.row.label}`
87}
88
89// The visible box drawn for an item, what the pane scrolls to.
90function viewKey(item: Item): string {
91 return `view:${itemKey(item)}`
92}
93
94// The session the pane sits in leads, in a group of its own.
95function groupsOf(list: InboxRow[], folded: Folds, current: InboxRow | null): { section: Section | null; items: Item[] }[] {
96 const lead = current ? [{ section: null, items: [{ kind: 'row', row: current } as Item] }] : []
97 return lead.concat(
98 ORDER.flatMap(section => {
99 const own = list.filter(row => row.section === section)
100 if (own.length === 0) return []
101 const items: Item[] = isFold(section) && folded[section] ? [{ kind: 'fold', section, count: own.length }] : own.map(row => ({ kind: 'row', row }))
102 return [{ section, items }]
103 }),
104 )
105}
106
107async function listed($: EngineInterface): Promise<Item[]> {
108 return groupsOf(await read($, rows), await read($, folds), await read($, current)).flatMap(group => group.items)
109}
110
111// The stored cursor may point past a list that has shrunk since it was set.
112function onList(flat: Item[], at: number): number {
113 return Math.min(at, Math.max(0, flat.length - 1))
114}
115
116async function readJson<T>($: EngineInterface, path: string, fallback: T): Promise<T> {
117 try {
118 return JSON.parse(await $.fs.read(path)) as T
119 } catch {
120 return fallback
121 }
122}
123
124export function rowOf(section: Section, one: SnapshotRow): InboxRow {
125 const pr = one.pr?.short && one.pr.url ? { short: one.pr.short, state: one.pr.state ?? '', url: one.pr.url } : undefined
126 return {
127 section,
128 id: one.id,
129 session: one.session,
130 label: one.label ?? one.id ?? '',
131 state: one.state ?? 'unknown',
132 actionable: one.actionable === true,
133 isWaiting: one.waiting === true,
134 line: one.line,
135 wakeAt: one.wake_at,
136 remote: one.remote,
137 pr,
138 }
139}
140
141function wakeLabel(wakeAt: number | 'until_woken'): string {
142 if (wakeAt === 'until_woken') return 'until woken'
143 const at = new Date(wakeAt * 1000)
144 return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
145}
146
147function ago(seconds: number): string {
148 return seconds < 60 ? 'just now' : `${Math.floor(seconds / 60)}m ago`
149}
150
151async function inboxFile($: EngineInterface, path: string): Promise<string> {
152 return `${(await $.env.get('HOME')) ?? ''}/${path}`
153}
154
155async function nowS($: EngineInterface): Promise<number> {
156 return Math.floor((await $.clock.now()) / 1000)
157}
158
159// Read each time: it is the transcript's name, which the daemon reports as sessionId,
160// and a /clear may mint a new one mid-session.
161async function sessionId($: EngineInterface, host: Host): Promise<string> {
162 return $.session.id().catch(() => host.session)
163}
164
165// Leaves one request for the inbox to apply; its poller takes it within half a second
166// and the next snapshot shows the row where the gem's own rules put it.
167async function ask($: EngineInterface, host: Host, action: Action): Promise<void> {
168 const actions = await inboxFile($, ACTIONS)
169 const at = Math.floor(await $.clock.now())
170 await $.fs.write(`${actions}/${host.session}-${at}.json`, JSON.stringify({ ...action, at }))
171}
172
173// Asks the inbox attached to this session for another one. It takes the file when it
174// does; a file still there after SWITCH_MS, and still ours, means no inbox is attached to
175// this session.
176async function requestSwitch($: EngineInterface, host: Host, id: string): Promise<void> {
177 await handBackKeys($)
178 const path = await inboxFile($, SWITCH)
179 const from = await sessionId($, host)
180 const at = await nowS($)
181 await $.fs.write(path, JSON.stringify({ id, from, at }))
182 $.ui.toast(`switching to ${id}…`)
183 $.clock.after(SWITCH_MS, () => {
184 void readJson<{ from?: string; at?: number } | null>($, path, null).then(async left => {
185 if (left === null || left.from !== from || left.at !== at) return
186 await $.fs.write(path, '')
187 $.ui.toast('no inbox is attached to this session: run claude-inbox, attach from it, and press Enter here')
188 })
189 })
190}
191
192// The pane keeps the keys while its session is away, so coming back to it would put the
193// person's typing in the pane. The engine has no blur: reopening without `focus` is the
194// one way to give the keys to the prompt.
195async function handBackKeys($: EngineInterface): Promise<void> {
196 await $.ui.close({ id: PANE })
197 await $.ui.open(OPEN)
198}
199
200// Only while no inbox writes the snapshot; the gem's writer lock turns a second one away.
201function startHeadless($: EngineInterface, host: Host, now: number): void {
202 if (host.isHeadlessRunning || now - host.headlessStartedAt < RESTART_S) return
203 host.headlessStartedAt = now
204 host.isHeadlessRunning = true
205 void (async () => {
206 try {
207 const child = $.process.spawn({ argv: [host.command, '--headless'] })
208 for await (const piece of child) {
209 if ('text' in piece) $.ui.log(`${host.command} --headless: ${piece.text.trim()}`, { to: 'debug' })
210 }
211 } catch (error) {
212 $.ui.log(`${host.command} --headless did not start: ${String(error)}`, { to: 'debug' })
213 } finally {
214 host.isHeadlessRunning = false
215 }
216 })()
217}
218
219async function poll($: EngineInterface, host: Host): Promise<void> {
220 const file = await inboxFile($, SNAPSHOT)
221 const now = await nowS($)
222 const snapshot = await readJson<Snapshot | null>($, file, null)
223 const written = snapshot?.written_at ?? 0
224 if (snapshot !== null) {
225 const all = ORDER.flatMap(section => (snapshot.sections?.[section] ?? []).map(one => rowOf(section, one)))
226 const id = await sessionId($, host)
227 await update($, current, () => all.find(one => one.session === id) ?? null)
228 await update($, rows, () => all.filter(one => one.session !== id))
229 }
230 await update($, writtenAt, () => written)
231 if (now - written > STALE_S) startHeadless($, host, now)
232}
233
234// Puts the ring on the item's Button, so Enter acts on it, and scrolls it into view.
235async function ringOn($: EngineInterface, item: Item): Promise<void> {
236 await $.ui.focus({ requestId: PANE, key: itemKey(item) }).catch(() => undefined)
237 await $.ui.scroll({ in: PANE, to: { key: viewKey(item) }, block: 'nearest' }).catch(() => undefined)
238}
239
240// Moves the cursor and keeps it on screen.
241async function moveTo($: EngineInterface, flat: Item[], to: number): Promise<void> {
242 const at = Math.max(0, Math.min(flat.length - 1, to))
243 await update($, cursor, () => at)
244 await update($, pending, () => null)
245 const item = flat[at]
246 if (item) await ringOn($, item)
247}
248
249async function setFold($: EngineInterface, list: InboxRow[], current: InboxRow | null, name: Fold | undefined, open: boolean): Promise<void> {
250 await update($, pending, () => null)
251 if (!name) return
252 const folded = await update($, folds, f => ({ ...f, [name]: !open }))
253 // The item under the cursor may have just become a fold, or the fold its rows.
254 const groups = groupsOf(list, folded, current)
255 const at = groups.findIndex(group => group.section === name)
256 if (at >= 0) await moveTo($, groups.flatMap(group => group.items), groups.slice(0, at).flatMap(group => group.items).length)
257}
258
259async function startChord($: EngineInterface, key: Pending): Promise<void> {
260 await update($, pending, () => key)
261 $.clock.after(CHORD_MS, () => void update($, pending, now => (now === key ? null : now)))
262}
263
264export const register: Register = (on, options) => {
265 const command = typeof options.command === 'string' && options.command !== '' ? options.command : 'claude-inbox'
266 const host: Host = { session: 'pane', command, headlessStartedAt: 0, isHeadlessRunning: false }
267
268 on('session.start', async ($, e, next) => {
269 await $.command.register({ name: 'inbox', description: 'Open the inbox of background sessions' })
270 const started = await next(e)
271 host.session = await $.session.id()
272 // A pinned line outlives a reload, so an earlier version's is cleared here.
273 $.ui.status(undefined)
274 void poll($, host)
275 $.clock.every(POLL_MS, () => void poll($, host))
276 if (options.openOnStart === false) return started
277 const opened = await $.ui.open(OPEN)
278 if (!opened.isPlaced) $.ui.log(`inbox: /inbox opens the pane (${opened.reason})`, { to: 'debug' })
279 return started
280 })
281
282 on('command.run', { command: 'inbox' }, async $ => {
283 void poll($, host)
284 const opened = await $.ui.open({ ...OPEN, focus: true })
285 const surfaces = (await $.session.surfaces()).join(', ') || 'none'
286 if (!opened.isPlaced) return { text: `Inbox pane not drawn: ${opened.reason}. Surfaces: ${surfaces}.` }
287 return { text: `Inbox pane open on ${surfaces}. ctrl+x tab focuses it; the keys are claude-inbox's.` }
288 })
289
290 // The ring walks the items' Buttons and nothing else: entering the pane it lands on
291 // the cursor's item, the arrows move it to the next, and the cursor follows.
292 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
293 const flat = await listed($)
294 if (e.element === undefined) {
295 const item = flat[onList(flat, await read($, cursor))]
296 return item ? next({ ...e, element: itemKey(item) }) : next(e)
297 }
298 const at = flat.findIndex(item => itemKey(item) === e.element)
299 if (at < 0) return {}
300 await update($, cursor, () => at)
301 return next(e)
302 })
303
304 // An arrow in a focused pane scrolls its body a row while the tree is taller than the
305 // window, and only walks the ring once it cannot: so the person's single-row scroll
306 // moves the cursor instead, and the wheel, the page keys and the pane's own scrolls pass.
307 on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
308 if (e.origin.kind !== 'person' || e.pointer !== undefined || Math.abs(e.by) !== 1) return next(e)
309 const flat = await listed($)
310 const at = onList(flat, await read($, cursor))
311 await moveTo($, flat, at + e.by)
312 return {}
313 })
314
315 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
316 const { Box, Text, Button } = $.ui.resolve(e)
317 const list = await read($, rows)
318 const viewing = await read($, current)
319 const folded = await read($, folds)
320 const chord = await read($, pending)
321 const written = await read($, writtenAt)
322 const ageS = (await nowS($)) - written
323 const groups = groupsOf(list, folded, viewing)
324 const flat = groups.flatMap(group => group.items)
325 const at = onList(flat, await read($, cursor))
326 const selected = flat[at]
327 const row = selected?.kind === 'row' ? selected.row : undefined
328 const section = selected?.kind === 'fold' ? selected.section : row === viewing ? undefined : row?.section
329 const fold = section && isFold(section) ? section : undefined
330 const heads = groups.map(group => flat.indexOf(group.items[0] as Item))
331 const notice =
332 written === 0
333 ? `claude-inbox is not running: starting ${command} --headless.`
334 : ageS > STALE_S
335 ? `claude-inbox stopped ${ago(ageS)}; rows are stale.`
336 : null
337
338 // The gem's header chips, compact: a glyph and a count per section that has rows.
339 const count = (of: (one: InboxRow) => boolean) => list.filter(of).length
340 const chips: [number, string, string | undefined][] = [
341 [count(one => one.section === 'pinned'), '★', 'cyan'],
342 [count(one => one.section === 'needs_you'), '●', 'red'],
343 [count(one => one.section === 'active' && one.state === 'working' && !one.isWaiting), '✻', 'yellow'],
344 [count(one => one.section === 'active' && one.isWaiting), '◌', 'yellow'],
345 [count(one => one.section === 'snoozed'), 'z', 'magenta'],
346 [count(one => one.section === 'settled'), '◦', undefined],
347 ]
348
349 const onRow = (action: 'settle' | 'wake' | 'pin', verb: string) => () => {
350 if (!row?.id || !row.actionable) return $.ui.toast('nothing here can be changed from the pane')
351 void ask($, host, { action, id: row.id }).then(() => $.ui.toast(`${verb} ${row.label}`))
352 }
353 const key = (hotkey: string, onPress: () => void) => <Button key={hotkey} hotkey={hotkey} plain label="" onPress={onPress} />
354 // Enter or a click on an item: the cursor lands on it, a fold opens, a row the inbox can
355 // attach to asks for the switch.
356 const enter = (item: Item) => () => {
357 void moveTo($, flat, flat.indexOf(item))
358 if (item.kind === 'fold') return void setFold($, list, viewing, item.section, true)
359 if (item.row === viewing) return $.ui.toast('you are in this session')
360 if (!item.row.id || !item.row.actionable) return $.ui.toast('this session cannot be attached')
361 void requestSwitch($, host, item.row.id)
362 }
363
364 // Only the keys a chord allows are mounted while it waits, so any other key is a no-op.
365 const keys =
366 chord === 'g'
367 ? [key('g', () => void moveTo($, flat, 0)), key('e', () => void moveTo($, flat, flat.length - 1))]
368 : chord === 'z'
369 ? [
370 key('a', () => void setFold($, list, viewing, fold, fold ? folded[fold] : false)),
371 key('o', () => void setFold($, list, viewing, fold, true)),
372 key('c', () => void setFold($, list, viewing, fold, false)),
373 ]
374 : chord === 's'
375 ? Object.entries(SNOOZE).map(([digit, { choice, label }]) =>
376 key(digit, () => {
377 if (!row?.id || !row.actionable) return $.ui.toast('nothing here can be snoozed')
378 void ask($, host, { action: 'snooze', id: row.id, choice }).then(() => {
379 void update($, pending, () => null)
380 $.ui.toast(`snoozed ${row.label} ${label}`)
381 })
382 }),
383 )
384 : [
385 key('j', () => void moveTo($, flat, at + 1)),
386 key('k', () => void moveTo($, flat, at - 1)),
387 key('g', () => void startChord($, 'g')),
388 key('n', () => void moveTo($, flat, heads.find(h => h > at) ?? heads[0] ?? 0)),
389 key('p', () => void moveTo($, flat, heads.filter(h => h < at).pop() ?? heads[heads.length - 1] ?? 0)),
390 key('z', () => void startChord($, 'z')),
391 key('h', () => void setFold($, list, viewing, fold, false)),
392 key('l', selected ? enter(selected) : () => undefined),
393 key('x', onRow('settle', 'settled')),
394 key('u', onRow('wake', 'woke')),
395 key('t', onRow('pin', 'pinned')),
396 key('s', () => void startChord($, 's')),
397 key('c', () => {
398 if (!row?.id) return
399 void $.ui.copy({ text: `claude attach ${row.id}`, surface: e.surface }).then(copied =>
400 $.ui.toast(copied.isCopied ? `copied: claude attach ${row.id}` : 'no clipboard here'),
401 )
402 }),
403 key('o', () => (row?.pr ? void $.process.run(['open', row.pr.url]) : $.ui.toast('no pull request linked'))),
404 key('w', () => (row?.remote ? void $.process.run(['open', row.remote]) : $.ui.toast('no claude.ai/code page for this session'))),
405 key('q', () => void $.ui.close({ id: PANE })),
406 ]
407
408 return (
409 <Box flexDirection="column">
410 {/* The engine draws its close mark on a row of its own above the body, so the header can't share it. */}
411 <Box columnGap={2}>
412 <Text color="cyan" bold>
413 claude-inbox
414 </Text>
415 {chips.filter(([n]) => n > 0).map(([n, glyph, color]) => (
416 <Text color={color} dimColor={color === undefined}>
417 {glyph} {n}
418 </Text>
419 ))}
420 {list.length === 0 && viewing === null && <Text dimColor>nothing running</Text>}
421 </Box>
422 {notice !== null && <Text color="yellow">{notice}</Text>}
423 {notice === null && flat.length === 0 && <Text dimColor>No sessions.</Text>}
424 {groups.map(group => (
425 <Box flexDirection="column" marginTop={1}>
426 <Text bold dimColor>
427 {group.section === null ? 'This session' : `${TITLE[group.section]} · ${list.filter(one => one.section === group.section).length}`}
428 </Text>
429 {group.items.map(item => {
430 const isHere = item === selected
431 if (item.kind === 'fold') {
432 return (
433 <Box key={viewKey(item)}>
434 <Text color={isHere ? 'yellow' : undefined}>{isHere ? '› ' : ' '}</Text>
435 <Button key={itemKey(item)} plain dimColor label={`▸ ${item.count} folded · za opens`} onPress={enter(item)} autoFocus={isHere || undefined} />
436 </Box>
437 )
438 }
439 const one = item.row
440 const isQuiet = isFold(one.section)
441 return (
442 <Box key={viewKey(item)} flexDirection="column">
443 <Box>
444 <Text color={isHere ? 'yellow' : undefined}>{isHere ? '› ' : ' '}</Text>
445 <Text color={COLOR[one.state]} dimColor={isQuiet || one.state === 'done'}>
446 {GLYPH[one.state] ?? '·'}{' '}
447 </Text>
448 {/* A Button cannot truncate, so its Box clips a long label to the row. */}
449 <Box height={1} flexShrink={1} overflow="hidden">
450 <Button
451 key={itemKey(item)}
452 plain
453 dimColor={isQuiet && !isHere}
454 label={one.wakeAt !== undefined ? `${one.label} · ${wakeLabel(one.wakeAt)}` : one.label}
455 onPress={enter(item)}
456 autoFocus={isHere || undefined}
457 />
458 </Box>
459 {/* The label gives up its cells to the PR chip, which would otherwise wrap under it. */}
460 {one.pr ? (
461 <Box flexShrink={0}>
462 <Text color={isQuiet ? undefined : PR_COLOR[one.pr.state]} dimColor={isQuiet}>
463 {' '}
464 {one.pr.short} {one.pr.state}
465 </Text>
466 </Box>
467 ) : null}
468 </Box>
469 {!isQuiet && one.line !== undefined ? (
470 <Box paddingLeft={4}>
471 <Text dimColor wrap="truncate-end">↳ {one.line}</Text>
472 </Box>
473 ) : null}
474 </Box>
475 )
476 })}
477 </Box>
478 ))}
479 <Box flexDirection="column" marginTop={1}>
480 {!e.props.isFocused && <Text dimColor>ctrl+x tab takes you here</Text>}
481 {e.props.isFocused && chord === null && <Text dimColor>esc leaves</Text>}
482 {e.props.isFocused && chord !== null && (
483 <Box flexDirection="column">
484 <Text dimColor>{chord}…</Text>
485 {MENU[chord].map(([key, what]) => (
486 <Text dimColor>
487 {' '}
488 <Text bold>{key}</Text> {what}
489 </Text>
490 ))}
491 </Box>
492 )}
493 </Box>
494 {/* Drawn nowhere: the hotkeys. */}
495 <Box height={0} overflow="hidden">
496 {keys}
497 </Box>
498 </Box>
499 )
500 })
501}
502types/index.d.ts 29 lines1export type Section = 'pinned' | 'needs_you' | 'active' | 'snoozed' | 'settled'
2
3// One row of the gem's snapshot.json, as its Snapshot class writes it.
4export type InboxRow = {
5 section: Section
6 id?: string
7 // The daemon's sessionId, the name $.session.id() gives the session the pane sits in.
8 session?: string
9 label: string
10 state: string
11 actionable: boolean
12 isWaiting: boolean
13 line?: string
14 wakeAt?: number | 'until_woken'
15 remote?: string
16 pr?: { short: string; state: string; url: string }
17}
18
19export type Folds = { snoozed: boolean; settled: boolean }
20
21// The first key of a chord (g, z, s) waiting for its second; null between chords.
22export type Pending = 'g' | 'z' | 's' | null
23
24declare module 'claude-code' {
25 interface PluginState {
26 'inbox-pane': { rows: InboxRow[]; current: InboxRow | null; writtenAt: number; cursor: number; folds: Folds; pending: Pending }
27 }
28}
29