SLOPSHOPPER

inbox-pane

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

newpanecommandtoaststatusprocess
★ 3v0.2.0MITupdated 2026-10-09jordanbyron/claude-inbox/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · inbox-pane
│ ┃ Inbox ✕ › fix the failing auth test and add an audit log call │ ┃ claude-inbox nothing running │ ┃ claude-inbox is not running: starting ⏺ Read(src/auth.ts) │ ┃ claude-inbox --headless. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ctrl+x tab takes you here ⎿ Added 2 lines, removed 1 line │ ┃ j: k: g: n: p: z: h: l: x: u: t: s: c: o: w: ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /inbox │ ⎿ inbox-pane: Inbox pane open on terminal. ctrl+x tab focuses it; │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Inbox
claude-inbox nothing running claude-inbox is not running: starting claude-inbox --headless. ctrl+x tab takes you here j: k: g: n: p: z: h: l: x: u: t: s: c: o: w: q:
README

claude-inbox

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.

claude-inbox with one working session, one snoozed and the settled section folded

Install

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.

Claude Code pane

The same inbox as a pane inside Claude Code, docked beside the transcript in a fullscreen terminal.

The inbox pane docked beside a Claude Code transcript

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

Sections

  1. Pinned. Rows you pinned with t.
  2. Needs you. Blocked or failed sessions you haven't attached to yet.
  3. Active. Everything else that's running or not yet settled, including interactive terminals.
  4. Snoozed. Sorted by wake time. Collapsed.
  5. Settled. Settled with 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.

Keys

Vim bindings. Arrows and the mouse work too.

KeyAction
j kmove down / up
gg Gfirst / last row
Ctrl-d Ctrl-uhalf page down / up (Ctrl-f Ctrl-b full page)
Enter lattach (← or Ctrl+Z returns here), adopt a remote session, or expand Snoozed / Settled
Tab Shift+Tabnext / previous section
hclose the peek pane, else collapse the current fold
za zo zctoggle / open / close the fold under the cursor
ptoggle the read-only peek pane
J K (Ctrl-e Ctrl-y)scroll the peek pane
nnew session
Npair a phone
tpin / unpin
ssnooze: 1 15m · 2 1h · 3 tomorrow 9am · 4 until woken
uwake a snoozed session, or bring back a settled one
xsettle by hand
aset a local alias
oopen the pull request in the browser
wopen the session at claude.ai/code
Plink a pull request by hand (empty clears)
Xstop the session; Enter resumes it later
Ctrl-xdelete the session, transcript and worktree for good
/filter; Enter keeps it, Esc clears
Rpoll now
qquit

X and Ctrl-x both ask for y first.

New session

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.

How sessions move

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.

Remote Control

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.

Starting sessions from your phone

With --listen-lan, a phone on your network can start sessions.

The inbox beside the phone form, pairing and then starting a session

  1. Run claude-inbox --listen-lan.
  2. Press N, then c to copy the pairing URL.
  3. Open it on the phone in Safari and tap Share, then Add to Home Screen.
  4. Open the Inbox icon, paste the URL again and tap Pair.

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

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.

Usage bars

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

Contributing

See CONTRIBUTING.md.

Source 2 files
hooks/register.tsx 502 lines
1import { 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}
502
types/index.d.ts 29 lines
1export 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