Sessions of this project, their worktrees and PRs, with check-ins, in a side pane.

Two mods for Claude Code for people who run many sessions at once: one line above the prompt that tells you what this session is costing, and one pane beside the chat that tells you what every other session is doing.
<img src="docs/demo-light.gif" alt="A walkthrough: click the bar to see the context breakdown, click PRs to open the sessions pane, open a session, ask about it, ask about everything in flight, unfold the done ones.">
Unofficial. Not affiliated with Anthropic.
context-card: one line above the prompt<img src="docs/card-light.png" alt="The context card with its breakdown open.">
| On the line | Meaning |
|---|---|
27% 272.3k / 1M | How full the context window is. Turns amber at 60%, red at 80%. |
● ◐ ○ | The prompt cache: fresh, past half its life, expired. Sending a message on an expired cache re-reads the whole context at full price. |
| The thin bar | The same percentage, colored by what is using it. |
weekly 20% ↻ 4d 21h | Your plan's weekly usage and when it resets, read live from your account. |
PRs | Opens the sessions pane. A number beside it counts sessions waiting on you. |
Click anywhere on the line to open the breakdown (messages, tools, MCP, skills, system, memory), the cache's minutes left, and where auto-compact will run. Click again to close it.
pr-pane: the sessions pane<img src="docs/sessions-light.png" alt="The sessions pane docked beside the chat.">
Every session in the same sidebar group, sorted by what it needs from you:
| Section | What is in it |
|---|---|
| Needs you | CI failing, conflicts, changes requested, ready to merge, or idle with nothing to show. |
| Working | Sessions running right now. |
| Done | Merged or closed. Folded until you click the heading. |
| Loose worktrees | Worktrees no session holds, and whether they have uncommitted changes. |
Each row has one time, on the right: how long it has been in the state its status names (working for 47m, merged 2h ago).
| Control | What it does |
|---|---|
| Click a session | Shows its worktree, branch, PR and activity. |
| Ask here | Asks, in the session you are in, for that session's status. Claude reads the other session's transcript and checks its PR; the other session is not messaged or interrupted. |
| Ask N working | The same, for every session that is running. |
#45, PR ↗ | Opens the pull request on GitHub. |
↻ | Refreshes now. The pane also refreshes itself every 15 seconds. |
| A section heading | Folds or unfolds the section. |
Outside any project, the pane shows every project's sessions as one overview.
Both mods together, from this repository:
/plugin marketplace add Nongfsq/frank-claude-cockpit
/plugin install context-card@frank-claude-cockpit
/plugin install pr-pane@frank-claude-cockpit
Or copy the two folders into ~/.claude/skills/ and start a new session:
git clone https://github.com/Nongfsq/frank-claude-cockpit
cp -R frank-claude-cockpit/context-card frank-claude-cockpit/pr-pane ~/.claude/skills/
Install both: the PRs button lives in context-card and appears only when pr-pane is present.
ui.render, $.state); they were built and tested on 2.1.286.context-card's weekly usage needs a Claude subscription login. With an API key the card still shows context, without the weekly figure.pr-pane needs the Claude desktop app, which is where the session list comes from; in a plain terminal it says the list is unavailable. PR and CI state come from the gh CLI, logged in.context-card asks api.anthropic.com for your own plan usage with the login Claude Code already holds, at most once every two minutes however many sessions are open: one session asks and the rest read its answer from ~/.claude/cache/context-card-usage.json. That request uses no tokens.pr-pane runs git and gh locally, in the project's folder.Nothing else is sent anywhere.
claude plugin validate context-card
claude plugin test context-card
claude plugin test pr-pane
Types for an editor: run /plugin-types inside a mod's folder.
The demo above is a scripted page, docs/demo/index.html; open it in a browser to watch it loop, or add ?theme=light. To re-record it:
cd docs/demo && npm i puppeteer-core
node record.mjs frames dark 15
ffmpeg -framerate 15 -i frames/f%04d.png -vf "scale=1200:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" ../demo-dark.gif
If this project is useful to you, you can buy me a coffee.
<a href="https://buymeacoffee.com/frankmenger"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="44"></a>
hooks/register.tsx 567 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, Kind, Loose, Report, Row } from '../types'
5
6const PANE = 'pr-pane'
7const TITLE = 'Sessions'
8const SERVER = 'ccd_session_mgmt'
9const POLL_MS = 15_000
10const PULLS_MS = 120_000
11const DONE_LIMIT = 8
12
13const board = atom({ plugin: 'pr-pane', key: 'board' } as const, null)
14const error = atom({ plugin: 'pr-pane', key: 'error' } as const, null)
15const isLoading = atom({ plugin: 'pr-pane', key: 'isLoading' } as const, false)
16const attention = atom({ plugin: 'pr-pane', key: 'attention' } as const, 0)
17const reports = atom({ plugin: 'pr-pane', key: 'reports' } as const, {})
18const checkedAt = atom({ plugin: 'pr-pane', key: 'checkedAt' } as const, 0)
19const expanded = atom({ plugin: 'pr-pane', key: 'expanded' } as const, [])
20// When each running session was first seen running, so a row can say how long its current run has lasted.
21const runs = atom({ plugin: 'pr-pane', key: 'runs' } as const, {})
22const openSections = atom({ plugin: 'pr-pane', key: 'openSections' } as const, ['needs', 'working'])
23
24// Theme keys, so both themes stay legible. Color means attention; merged gets the theme's own merged hue.
25const TONES: Record<Kind, string | undefined> = {
26 ready: 'success',
27 waiting: 'warning',
28 fix: 'error',
29 working: 'suggestion',
30 merged: 'merged',
31 closed: undefined,
32}
33const NEEDS: Kind[] = ['fix', 'ready', 'waiting']
34
35type Session = {
36 sessionId: string
37 title: string
38 cwd: string
39 branch?: string
40 isRunning: boolean
41 prNumber?: number
42 prState?: 'OPEN' | 'MERGED' | 'CLOSED'
43 lastActivityAt: string
44 group?: { id: string; name: string } | null
45}
46
47type OpenPull = {
48 number: number
49 isDraft: boolean
50 reviewDecision: string
51 mergeable: string
52 url: string
53 statusCheckRollup: { conclusion?: string; status?: string; state?: string }[]
54}
55
56const FAILED = ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
57
58const checksOf = (rollup: OpenPull['statusCheckRollup']) => {
59 if (rollup.some(c => FAILED.includes(c.conclusion ?? '') || FAILED.includes(c.state ?? ''))) return 'fail'
60 if (rollup.some(c => (c.status !== undefined && c.status !== 'COMPLETED') || c.state === 'PENDING')) return 'pending'
61
62 return 'pass'
63}
64
65// Lifts a task id out of an AI-written title: "Fix TASK-183: unify recovery" → TASK-183, "unify recovery".
66const splitTitle = (title: string) => {
67 const match = title.match(/^(?:\S+\s+){0,3}?([A-Z][A-Z0-9]+-\d+)\s*[::\-–]?\s*(.+)$/)
68
69 return match ? { tag: match[1]!, name: match[2]! } : { tag: '', name: title }
70}
71
72const kindOf = (s: Session, pull: OpenPull | undefined): { kind: Kind; status: string } => {
73 if (s.prState === 'MERGED') return { kind: 'merged', status: 'merged' }
74 if (s.prState === 'CLOSED') return { kind: 'closed', status: 'closed' }
75
76 const checks = pull === undefined ? 'pass' : checksOf(pull.statusCheckRollup ?? [])
77
78 if (s.isRunning) return { kind: 'working', status: checks === 'pending' ? 'working · CI running' : 'working' }
79 if (s.prState !== 'OPEN') return { kind: 'waiting', status: 'no report yet' }
80 if (pull === undefined) return { kind: 'waiting', status: 'PR open' }
81 if (checks === 'fail') return { kind: 'fix', status: 'CI failing' }
82 if (pull.mergeable === 'CONFLICTING') return { kind: 'fix', status: 'conflicts' }
83 if (pull.reviewDecision === 'CHANGES_REQUESTED') return { kind: 'fix', status: 'changes requested' }
84 if (pull.isDraft) return { kind: 'waiting', status: 'draft' }
85 if (checks === 'pending') return { kind: 'working', status: 'CI running' }
86 if (pull.reviewDecision === 'REVIEW_REQUIRED') return { kind: 'waiting', status: 'needs review' }
87
88 return { kind: 'ready', status: 'ready to merge' }
89}
90
91const ago = (ms: number) => {
92 const seconds = Math.max(0, Math.round(ms / 1000))
93
94 if (seconds < 60) return `${seconds}s`
95 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
96 if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`
97
98 return `${Math.floor(seconds / 86_400)}d`
99}
100
101const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text)
102const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? path
103
104const mcpJson = async ($: EngineInterface, tool: string, args: Record<string, unknown>) => {
105 const result = await $.mcp.call(SERVER, tool, args)
106 const text = result.content.find(block => block.type === 'text')?.text ?? ''
107
108 if (result.isError) {
109 throw new Error(text.split('\n')[0] || `${tool} failed`)
110 }
111
112 // An empty list comes back as a sentence ("No other sessions found in group ..."), not as `[]`.
113 if (tool === 'list_sessions' && !text.trimStart().startsWith('[')) {
114 return []
115 }
116
117 return JSON.parse(text) as unknown
118}
119
120const run = async ($: EngineInterface, argv: string[], cwd?: string) => {
121 const result = await $.process.run(argv, { cwd, timeoutMs: 20_000 })
122
123 return { ok: result.exitCode === 0, out: result.stdout.trim() }
124}
125
126let ghPath: string | null = null
127
128// gh may be off the host's PATH; fall back to Homebrew's location.
129const gh = async ($: EngineInterface, args: string[], cwd: string) => {
130 for (const candidate of ghPath === null ? ['gh', '/opt/homebrew/bin/gh', '/usr/local/bin/gh'] : [ghPath]) {
131 try {
132 const result = await run($, [candidate, ...args], cwd)
133 ghPath = candidate
134
135 return result
136 } catch {
137 continue
138 }
139 }
140
141 return { ok: false, out: '' }
142}
143
144let pulls = new Map<number, OpenPull>()
145let repoName = ''
146let pullsAt = 0
147
148const refreshPulls = async ($: EngineInterface, root: string, now: number) => {
149 const [repo, list] = await Promise.all([
150 gh($, ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], root),
151 gh($, ['pr', 'list', '--state', 'open', '--limit', '50', '--json', 'number,isDraft,reviewDecision,mergeable,statusCheckRollup,url'], root),
152 ])
153
154 repoName = repo.ok ? repo.out : repoName
155 pulls = list.ok ? new Map((JSON.parse(list.out) as OpenPull[]).map(p => [p.number, p] as const)) : pulls
156 pullsAt = now
157}
158
159const looseWorktrees = async ($: EngineInterface, root: string, taken: Set<string>): Promise<Loose[]> => {
160 const listed = await run($, ['git', 'worktree', 'list', '--porcelain'], root)
161
162 if (!listed.ok) {
163 return []
164 }
165
166 const paths = listed.out
167 .split('\n\n')
168 .filter(block => !block.includes('\nprunable'))
169 .map(block => block.split('\n').find(line => line.startsWith('worktree '))?.slice(9) ?? '')
170 .filter(path => path.startsWith(`${root}/`) && !taken.has(path))
171 .slice(0, 10)
172
173 const found = await Promise.all(
174 paths.map(async path => {
175 const status = await run($, ['git', '-C', path, 'status', '--porcelain']).catch(() => null)
176
177 return status?.ok ? { path, name: basename(path), dirty: status.out === '' ? 0 : status.out.split('\n').length } : null
178 }),
179 )
180
181 return found.filter((tree): tree is Loose => tree !== null)
182}
183
184const refresh = async ($: EngineInterface, force = false) => {
185 if (await read($, isLoading)) {
186 return
187 }
188
189 await update($, isLoading, () => true)
190
191 try {
192 const me = (await mcpJson($, 'get_session', { session_id: 'self' })) as Session
193 const group = me.group ?? null
194 const listed = (await mcpJson($, 'list_sessions', group === null ? { limit: 50 } : { group: group.id, limit: 50 })) as Session[]
195 const common = await run($, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], me.cwd)
196 const root = common.ok ? common.out.replace(/\/\.git$/, '') : ''
197 // Outside any project (no group, no repo) the pane is an overview of every project's sessions.
198 const isOverview = group === null && root === ''
199 const sessions = isOverview ? listed : group === null ? listed.filter(s => s.cwd.startsWith(root)) : listed
200 const now = await $.clock.now()
201
202 if (root !== '' && (force || now - pullsAt >= PULLS_MS)) {
203 await refreshPulls($, root, now)
204 }
205
206 const seen: Record<string, number> = await read($, runs)
207 const running = Object.fromEntries(sessions.filter(s => s.isRunning).map(s => [s.sessionId, seen[s.sessionId] ?? now]))
208 await update($, runs, () => running)
209
210 const rows: Row[] = sessions.map(s => {
211 const pull = s.prNumber === undefined ? undefined : pulls.get(s.prNumber)
212 const { tag, name } = splitTitle(s.title)
213
214 return {
215 id: s.sessionId,
216 tag,
217 name,
218 worktree: basename(s.cwd),
219 branch: s.branch ?? '',
220 ...kindOf(s, pull),
221 pr: s.prNumber ?? 0,
222 prUrl: pull?.url ?? (s.prNumber !== undefined && repoName !== '' ? `https://github.com/${repoName}/pull/${s.prNumber}` : ''),
223 isRunning: s.isRunning,
224 lastActivityAt: s.lastActivityAt,
225 since: running[s.sessionId] ?? Date.parse(s.lastActivityAt),
226 project: isOverview ? (s.group?.name ?? basename(s.cwd.split('/.claude/worktrees/')[0] ?? s.cwd)) : '',
227 }
228 })
229
230 const taken = new Set([me.cwd, ...sessions.map(s => s.cwd)])
231 const loose = root === '' ? [] : await looseWorktrees($, root, taken)
232 const next: Board = {
233 group: isOverview ? 'All projects' : (group?.name ?? basename(root)),
234 me: { id: me.sessionId, title: me.title },
235 rows,
236 loose,
237 at: now,
238 }
239
240 await update($, board, () => next)
241 await update($, attention, () => rows.filter(row => NEEDS.includes(row.kind)).length)
242 await update($, error, () => null)
243 } catch (cause) {
244 await update($, error, () => `Session list unavailable: ${cause instanceof Error ? cause.message : String(cause)}`)
245 } finally {
246 await update($, isLoading, () => false)
247 }
248}
249
250// Asks about other sessions in this one: the prompt lands in this session's own chat, and Claude reads their
251// transcripts to answer, so the sessions asked about are not interrupted.
252const askHere = async ($: EngineInterface, rows: Row[]) => {
253 if (rows.length === 0) {
254 return
255 }
256
257 const list = rows.map(row => `- "${row.tag === '' ? row.name : `${row.tag} ${row.name}`}" (session ${row.id}${row.pr > 0 ? `, PR #${row.pr}` : ''})`)
258 const text =
259 `Report the status of ${rows.length === 1 ? 'this session' : 'these sessions'} without messaging ${rows.length === 1 ? 'it' : 'them'}: ` +
260 `read each one's recent transcript with the ccd_session_mgmt list_events tool, and check its PR and CI if it has one.\n` +
261 `${list.join('\n')}\n` +
262 `For each, in one or two lines: what it is doing now, how far along it is, any blocker, and whether it is ready to merge.`
263
264 await $.prompt.submit({ text, asUser: true })
265 const now = await $.clock.now()
266 await update($, checkedAt, () => now)
267}
268
269// The sessions worth asking about: the ones working now, or failing that the unfinished idle ones.
270const askable = (rows: Row[]) => {
271 const working = rows.filter(row => row.kind === 'working')
272
273 return working.length > 0 ? working : rows.filter(row => row.kind !== 'merged' && row.kind !== 'closed')
274}
275
276const checkIn = async ($: EngineInterface) => {
277 const data = await read($, board)
278
279 if (data !== null) {
280 await askHere($, askable(data.rows))
281 }
282}
283
284const toggleRow = async ($: EngineInterface, id: string) =>
285 update($, expanded, was => (was.includes(id) ? was.filter(one => one !== id) : [...was, id]))
286
287const toggleSection = async ($: EngineInterface, id: string) =>
288 update($, openSections, was => (was.includes(id) ? was.filter(one => one !== id) : [...was, id]))
289
290// Toggles the pane: the context card's Sessions button and a typed /sessions both land here.
291const togglePane = async ($: EngineInterface) => {
292 const panes = await $.ui.panes()
293
294 if (panes.some(pane => pane.id === PANE && pane.isPlaced)) {
295 await $.ui.close({ id: PANE })
296
297 return
298 }
299
300 const opened = await $.ui.open({ id: PANE, title: TITLE })
301
302 if (!opened.isPlaced) $.ui.toast(`Sessions pane did not open: ${opened.reason ?? 'no reason given'}`)
303
304 await refresh($, true)
305}
306
307/** Runs the toggle, and says why on screen when it fails rather than doing nothing. */
308const pressPane = async ($: EngineInterface) => {
309 try {
310 await togglePane($)
311 } catch (cause) {
312 $.ui.toast(`Sessions pane failed: ${cause instanceof Error ? cause.message : String(cause)}`)
313 }
314}
315
316export const register: Register = on => {
317 let ticker: { cancel: () => void } | null = null
318
319 on('session.start', async ($, e, next) => {
320 await $.command.register({ name: 'sessions', description: 'Show the sessions of this project and what each needs' })
321 await $.command.register({ name: 'prs', description: 'Same as /sessions' })
322 await refresh($, true)
323 ticker?.cancel()
324 ticker = $.clock.every(POLL_MS, () => void refresh($))
325
326 return next(e)
327 })
328
329 on('session.end', ($, e, next) => {
330 ticker?.cancel()
331 ticker = null
332
333 return next(e)
334 })
335
336 on('turn.complete', async ($, e, next) => {
337 if (e.agentId === undefined) {
338 void refresh($)
339 }
340
341 return next(e)
342 })
343
344 // A report a check-in asked for: keep its first line beside the session it names.
345 on('session.receive', async ($, e, next) => {
346 const match = e.text.match(/\[status (local_[A-Za-z0-9-]+)\]\s*([^\n]*)/)
347
348 if (match !== null) {
349 const now = await $.clock.now()
350 const id = match[1]!
351 const reply = cut(match[2]!.trim(), 200)
352 await update($, reports, was => ({ ...was, [id]: { askedAt: was[id]?.askedAt ?? now, reply, repliedAt: now } }))
353 }
354
355 return next(e)
356 })
357
358 on('command.run', { command: 'sessions' }, async $ => {
359 await pressPane($)
360
361 return {}
362 })
363
364 // The context card's PRs button: it counts its presses in its own state, and each write toggles the pane.
365 on('state.set', { plugin: 'context-card', key: 'paneAsk' }, async ($, e, next) => {
366 const result = await next(e)
367 void pressPane($)
368
369 return result
370 })
371
372 on('command.run', { command: 'prs' }, async $ => {
373 await pressPane($)
374
375 return {}
376 })
377
378 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
379 const { Box, Button, Link, Text } = $.ui.resolve(e)
380 const now = await $.clock.now()
381 const data = await read($, board)
382 const problem = await read($, error)
383 const traces: Record<string, Report> = await read($, reports)
384 const asked = await read($, checkedAt)
385 const open: string[] = await read($, expanded)
386 const sections: string[] = await read($, openSections)
387 const columns = e.props.bodyColumns ?? 60
388 const isNarrow = columns < 44
389 const isDesktop = e.surface === 'desktop'
390 // The grid every line sits on: a mark column, then a tag column that the detail labels share, then the text.
391 // A cell is about one monospace character on both surfaces; desktop's proportional text runs narrower than
392 // its cell count, so its tag column needs one cell less of gap than the terminal's.
393 const MARK = 2
394 const longestTag = Math.max(0, ...(data?.rows ?? []).map(row => row.tag.length))
395 const tagColumn = isDesktop ? Math.max(9, longestTag + 1) : Math.max(10, longestTag + 2)
396 const hasTags = !isNarrow && longestTag > 0
397 const titleRoom = Math.max(12, columns - MARK - (hasTags ? tagColumn : 0) - 6)
398 const keys = (hotkey: string) => (isDesktop ? {} : { hotkey })
399
400 // Desktop: a label-less control laid over a whole line, so a click anywhere on it presses, with no hover block.
401 const cover = (key: string, onPress: () => void) => (
402 <Box position="absolute" top={0} left={0} right={0} bottom={0} overflow="hidden">
403 {/* Wider than any line, so the overlay clips the control and its label is never cut with an ellipsis. */}
404 <Box width={1200} flexShrink={0}>
405 <Button key={key} plain label={'\u00A0'.repeat(600)} hover={{ backgroundColor: '#00000000' }} onPress={onPress} />
406 </Box>
407 </Box>
408 )
409
410 if (data === null) {
411 return (
412 <Box flexDirection="column" rowGap={1} paddingX={1}>
413 <Text dimColor>{problem ?? 'Loading sessions…'}</Text>
414 <Button key="refresh" plain {...(e.surface === 'desktop' ? {} : { hotkey: 'r' })} label="↻ refresh" onPress={() => void refresh($, true)} />
415 </Box>
416 )
417 }
418
419 const needs = data.rows.filter(row => NEEDS.includes(row.kind)).sort((a, b) => NEEDS.indexOf(a.kind) - NEEDS.indexOf(b.kind))
420 const working = data.rows.filter(row => row.kind === 'working')
421 const done = data.rows.filter(row => row.kind === 'merged' || row.kind === 'closed').slice(0, DONE_LIMIT)
422 const targets = askable(data.rows)
423 const targetLabel = `${targets.length} ${targets.some(row => row.kind === 'working') ? 'working' : 'open'}`
424
425 const sessionRow = (row: Row) => {
426 const isOpen = open.includes(row.id)
427 const trace = traces[row.id]
428 const hasReply = trace !== undefined && trace.repliedAt > 0
429 const tone = TONES[row.kind]
430 // One time per row, always how long it has been in the state its status names.
431 const age = ago(now - row.since)
432 const quiet = now - Date.parse(row.lastActivityAt)
433 const toggle = () => void toggleRow($, row.id)
434
435 return (
436 <Box key={`row-${row.id}`} flexDirection="column" marginBottom={isOpen ? 1 : 0}>
437 <Box key={`head-${row.id}`} flexDirection="row" position="relative">
438 <Box width={MARK} flexShrink={0}>
439 <Text color={tone} dimColor={tone === undefined}>
440 {row.kind === 'working' ? '◐' : '●'}
441 </Text>
442 </Box>
443 {hasTags && row.tag !== '' ? (
444 <Box width={tagColumn} flexShrink={0}>
445 <Text dimColor>{row.tag}</Text>
446 </Box>
447 ) : null}
448 {isDesktop ? (
449 <Text bold={isOpen} wrap="truncate-end">
450 {row.name}
451 </Text>
452 ) : (
453 <Button key={`title-${row.id}`} plain label={cut(row.name, titleRoom)} onPress={toggle} />
454 )}
455 <Box flexGrow={1} />
456 <Text dimColor>{age}</Text>
457 {isDesktop ? cover(`title-${row.id}`, toggle) : null}
458 </Box>
459 <Box flexDirection="row" columnGap={2} paddingLeft={MARK}>
460 <Text color={tone} dimColor={tone === undefined} wrap="truncate-end">
461 {row.status}
462 </Text>
463 {row.pr > 0 && row.prUrl !== '' ? <Link href={row.prUrl} label={`#${row.pr}`} /> : null}
464 {row.pr > 0 && row.prUrl === '' ? <Text dimColor>{`#${row.pr}`}</Text> : null}
465 <Box flexGrow={1} />
466 {row.project === '' ? null : <Text dimColor wrap="truncate-end">{row.project}</Text>}
467 </Box>
468 {hasReply ? (
469 <Box flexDirection="row" paddingLeft={MARK}>
470 <Box width={2} flexShrink={0}>
471 <Text color="success">↩</Text>
472 </Box>
473 <Text wrap="truncate-end">{trace.reply}</Text>
474 <Box flexGrow={1} />
475 <Text dimColor>{ago(now - trace.repliedAt)}</Text>
476 </Box>
477 ) : null}
478 {isOpen ? (
479 <Box flexDirection="column" rowGap={1} paddingLeft={MARK} marginTop={1}>
480 <Box flexDirection="column">
481 {[
482 ['worktree', row.worktree],
483 ['branch', row.branch],
484 ['PR', row.pr > 0 ? `#${row.pr} · ${row.status}` : 'none yet'],
485 ['activity', row.isRunning ? `working ${age}${quiet >= 60_000 ? ` · quiet ${ago(quiet)}` : ''}` : `idle ${age}`],
486 ].map(([label, value]) => (
487 <Box key={`${row.id}-${label}`} flexDirection="row">
488 <Box width={tagColumn} flexShrink={0}>
489 <Text dimColor>{label}</Text>
490 </Box>
491 <Text wrap="truncate-end">{value}</Text>
492 </Box>
493 ))}
494 </Box>
495 <Box flexDirection="row" columnGap={2}>
496 <Button key={`ask-${row.id}`} variant="secondary" label="Ask here" onPress={() => void askHere($, [row])} />
497 {row.prUrl === '' ? null : <Link href={row.prUrl} label="PR ↗" />}
498 </Box>
499 </Box>
500 ) : null}
501 </Box>
502 )
503 }
504
505 // A section's heading is its own switch: bold while open, dim while folded, and no arrow.
506 const section = (id: string, label: string, n: number, body: () => unknown) => {
507 const isOpen = sections.includes(id)
508 const toggle = () => void toggleSection($, id)
509
510 return n === 0 ? null : (
511 <Box key={`section-${id}`} flexDirection="column" marginTop={1}>
512 <Box key={`heading-${id}`} flexDirection="row" columnGap={1} position="relative">
513 {isDesktop ? (
514 <Text bold={isOpen} dimColor={!isOpen}>
515 {label}
516 </Text>
517 ) : (
518 <Button key={`section-${id}`} plain label={isOpen ? label : `${label} …`} onPress={toggle} />
519 )}
520 <Text dimColor>{n}</Text>
521 {isDesktop ? cover(`section-${id}`, toggle) : null}
522 </Box>
523 {isOpen ? body() : null}
524 </Box>
525 )
526 }
527
528 return (
529 <Box flexDirection="column" paddingX={1}>
530 <Box flexDirection="row" columnGap={2} alignItems="center">
531 <Text bold wrap="truncate-end">
532 {data.group}
533 </Text>
534 <Text dimColor>{asked > 0 ? `asked ${ago(now - asked)} ago` : `live · ${ago(now - data.at)}`}</Text>
535 <Box flexGrow={1} />
536 {targets.length === 0 ? null : (
537 <Button key="checkin" variant="secondary" {...keys('c')} label={`Ask ${targetLabel}`} onPress={() => void checkIn($)} />
538 )}
539 <Button key="refresh" plain {...keys('r')} label="↻" onPress={() => void refresh($, true)} />
540 </Box>
541
542 {problem === null ? null : <Text color="error">{problem}</Text>}
543
544 {data.rows.length === 0 ? (
545 <Box marginTop={1}>
546 <Text dimColor>No other sessions in this group yet.</Text>
547 </Box>
548 ) : null}
549 {section('needs', 'Needs you', needs.length, () => needs.map(sessionRow))}
550 {section('working', 'Working', working.length, () => working.map(sessionRow))}
551 {section('done', 'Done', done.length, () => done.map(sessionRow))}
552 {section('loose', 'Loose worktrees', data.loose.length, () =>
553 data.loose.map(tree => (
554 <Box key={`loose-${tree.path}`} flexDirection="row" columnGap={2} paddingLeft={MARK}>
555 <Text wrap="truncate-end">{tree.name}</Text>
556 <Box flexGrow={1} />
557 <Text color={tree.dirty > 0 ? 'warning' : undefined} dimColor={tree.dirty === 0}>
558 {tree.dirty > 0 ? `${tree.dirty} changed` : 'clean'}
559 </Text>
560 </Box>
561 )),
562 )}
563 </Box>
564 )
565 })
566}
567types/index.d.ts 58 lines1/** What a session row needs next, which decides its section and color. */
2export type Kind = 'ready' | 'waiting' | 'fix' | 'working' | 'merged' | 'closed'
3
4export type Row = {
5 /** The desktop app's session id (`local_...`). */
6 id: string
7 /** A task id lifted from the title (`TASK-183`), or empty. */
8 tag: string
9 /** The title without its task id and verb. */
10 name: string
11 worktree: string
12 branch: string
13 kind: Kind
14 /** One short status phrase (`ready to merge`, `CI failing`). */
15 status: string
16 pr: number
17 prUrl: string
18 isRunning: boolean
19 /** ISO time of the session's last activity. */
20 lastActivityAt: string
21 /** Epoch ms its current state began: the run for a running session, the last activity otherwise. */
22 since: number
23 /** The session's project, shown only in the all-projects overview; empty otherwise. */
24 project: string
25}
26
27export type Loose = { path: string; name: string; dirty: number }
28
29export type Board = {
30 group: string
31 /** This session's own id and title, so check-ins can name where to reply. */
32 me: { id: string; title: string }
33 rows: Row[]
34 loose: Loose[]
35 /** Epoch ms of the last successful refresh. */
36 at: number
37}
38
39/** A check-in's trace per session: when it was asked, and what it answered. */
40export type Report = { askedAt: number; reply: string; repliedAt: number }
41
42declare module 'claude-code' {
43 interface PluginState {
44 'context-card': { paneAsk: number }
45 'pr-pane': {
46 board: Board | null
47 error: string | null
48 isLoading: boolean
49 attention: number
50 reports: Record<string, Report>
51 checkedAt: number
52 runs: Record<string, number>
53 expanded: string[]
54 openSections: string[]
55 }
56 }
57}
58