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