SLOPSHOPPER

pr-pane

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

newpanecommandtoastprocesstimer
★ 4v0.1.0MITupdated 2026-10-03Nongfsq/frank-claude-cockpit/pr-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-pane
│ ┃ pr-pane ✕ › fix the failing auth test and add an audit log call │ ┃ Session list unavailable: undefined is not │ ┃ an object (evaluating ⏺ Read(src/auth.ts) │ ┃ 'result.content.find') ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ r: ↻ refresh ⎿ Added 2 lines, removed 1 line │ ⏺ 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 │ │ › /sessions │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pr-pane
Session list unavailable: undefined is not an object (evaluating 'result.content.find') r: ↻ refresh
README

Claude Code Cockpit

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.

What you get

context-card: one line above the prompt

<img src="docs/card-light.png" alt="The context card with its breakdown open.">

On the lineMeaning
27% 272.3k / 1MHow 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 barThe same percentage, colored by what is using it.
weekly 20% ↻ 4d 21hYour plan's weekly usage and when it resets, read live from your account.
PRsOpens 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:

SectionWhat is in it
Needs youCI failing, conflicts, changes requested, ready to merge, or idle with nothing to show.
WorkingSessions running right now.
DoneMerged or closed. Folded until you click the heading.
Loose worktreesWorktrees 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).

ControlWhat it does
Click a sessionShows its worktree, branch, PR and activity.
Ask hereAsks, 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 workingThe 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 headingFolds or unfolds the section.

Outside any project, the pane shows every project's sessions as one overview.

Install

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.

Requirements

  • A recent Claude Code. These mods use the hooks-module API (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.

What leaves your machine

  • 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.
  • Ask here sends one prompt in your current session, so it costs what a prompt costs there.

Nothing else is sent anywhere.

Development

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

Support

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>

License

MIT

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