SLOPSHOPPER

Slice Board

A compact pane beside the chat for the open vibe-coding round: progress of the round and of every slice in progress, a link to each slice's session, and one…

newpanecommandtoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slice-board
│ ┃ Slices ✕ › fix the failing auth test and add an audit log call │ ┃ No round here: work/_slices.json not found. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /slices │ ⎿ slice-board: Slice pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Slices
No round here: work/_slices.json not found.
README

Slice Board

A compact pane beside the chat for the open round of vibe-coding. It is a Claude Code mod: it runs in the CLI, the Code tab of the Desktop app and the VS Code extension.

It is laid out like the app's context pane and refreshed every ten seconds while it is open. Two refreshes never run at once, and none runs while the pane is closed. Every row is plain text, with no timers of its own, so nothing on the pane goes blank under load.

  • The round: the share of done slices, a thin bar with one segment per group, and a legend with count and share per group: Done, In progress, Ready, Blocked. Each legend row unfolds its slices.
  • In progress (unfolded by default): one row per slice with a dot for its state (working, awaiting plan approval, idle), the next checklist step with the slice's share (Plan, Red, Green, Checks, Commit), and ↗, which brings the slice's session to the front.
  • Ready: ▶ per slice and a Start N button above the groups write a request into the prompt box to start one session per slice, at most three slices in progress at once. Each session is briefed with /slice NN parallel auto: it goes on with its plan's assumptions without an approval round, stops only on an open decision (NEEDS-DECISION), ends with SLICE-READY: NN, and the squash happens through the merge queue in the main checkout (slice-loop --merge, or the running loop), not in the session. Enter sends it as your message and Claude starts the sessions under the session's own permission check; a start straight from the pane would have no conversation behind it, and auto mode refuses such a call. A blocked slice joins the ready ones as soon as the slices it depends on are done; a toast says so, and another when a plan waits for approval.

It reads work/_slices.json, work/checklist.md, work/rounds/, work/conventions.md and the slice-NN branches and worktrees of the repository the session runs in. The request asks for /slice NN parallel auto with the slice's goal, scope and acceptance cases, plus work/session-hints.md when the repository has one. Sessions are matched to slices by their title, Slice NN ….

Install

claude plugin install slice-board@vompa

The pane opens only in the chat that runs the round: with /slices, /slice alone, /slice status, /slice close or /vibe-coding. A slice session works on /slice NN and gets no pane, and a session without the pane runs no git. After /reload-plugins a session that had the pane opens it again.

Develop

claude plugin validate plugins/slice-board
claude plugin test plugins/slice-board
Source 2 files
hooks/register.tsx 452 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, SliceRow, SliceSession, SliceState } from '../types'
5
6const PANE = 'slice-board'
7const TITLE = 'Slices'
8const REFRESH_MS = 10_000
9const SESSIONS_MS = 30_000
10const STEPS = ['Plan', 'Red', 'Green', 'Checks', 'Commit']
11const LIST_SESSIONS = 'mcp__ccd_session_mgmt__list_sessions'
12const OPEN_SESSION = 'mcp__ccd_window__open_session_in'
13/** Parallel builds and test suites turn each other red under load: at most this many slices in progress at once (four were almost too many). */
14const MAX_PARALLEL = 3
15
16/** The groups of the pane in the order of the bar and the legend, with a fill for the desktop and a theme color for the terminal. */
17const GROUPS: { state: SliceState; label: string; fill: string; cell: string }[] = [
18  { state: 'done', label: 'Done', fill: '#34a06f', cell: 'success' },
19  { state: 'active', label: 'In progress', fill: '#d97757', cell: 'claude' },
20  { state: 'ready', label: 'Ready', fill: '#3b82f6', cell: 'blue' },
21  { state: 'blocked', label: 'Blocked', fill: '#b9b9b9', cell: 'inactive' },
22]
23
24const board = atom({ plugin: 'slice-board', key: 'board' } as const, null)
25const expanded = atom({ plugin: 'slice-board', key: 'expanded' } as const, { done: false, active: true, ready: false, blocked: false })
26/** Whether this session asked for the pane; a reload of the plugin opens it again. */
27const isWanted = atom({ plugin: 'slice-board', key: 'isWanted' } as const, false)
28
29/**
30 * The commands of the chat that runs a round: `/slice` alone, `/slice status`, `/slice close`, `/vibe-coding`. A slice
31 * session works on `/slice NN` and gets no pane.
32 */
33function asksForPane(text: string, command?: { name: string; args: string }): boolean {
34  const typed = /^\/([\w:-]+)\s*(.*)$/s.exec(text.trim())
35  const name = command?.name ?? typed?.[1] ?? ''
36  const args = (command?.args ?? typed?.[2] ?? '').trim()
37  if (name === 'slices' || name === 'vibe-coding' || name.endsWith(':vibe-coding')) return true
38  return name === 'slice' && /^(status|close)?(\s|$)/.test(args)
39}
40
41type Slice = {
42  nr: string
43  slug: string
44  name: string
45  round: string
46  deps: string
47}
48type SessionEntry = { sessionId?: string; title?: string; cwd?: string; link?: string; isRunning?: boolean; isArchived?: boolean }
49
50/** Ready slices and slices awaiting approval at the last refresh; a change is announced once. */
51let announced: { ready: Set<string>; approval: Set<string> } | null = null
52let sessionCache: { at: number; entries: SessionEntry[] } = { at: 0, entries: [] }
53
54const clip = (text: string, width: number) => (text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text)
55const samePath = (a: string, b: string) => a.replace(/\\/g, '/').replace(/\/$/, '').toLowerCase() === b.replace(/\\/g, '/').replace(/\/$/, '').toLowerCase()
56
57async function git($: EngineInterface, cwd: string, args: string[]): Promise<string> {
58  const { exitCode, stdout } = await $.process.run(['git', ...args], { cwd, timeoutMs: 15_000 })
59  return exitCode === 0 ? stdout.trim() : ''
60}
61
62async function readText($: EngineInterface, path: string): Promise<string | null> {
63  try {
64    return (await $.fs.read(path)) as string
65  } catch {
66    return null
67  }
68}
69
70/** Ticks per slice number from a checklist table: `| 12 web-money-pos | ☑ | ☐ | … |`. */
71function checklistTicks(text: string | null): Map<string, boolean[]> {
72  const ticks = new Map<string, boolean[]>()
73  for (const line of (text ?? '').split(/\r?\n/)) {
74    const cells = line.split('|').map(cell => cell.trim())
75    const nr = /^(\d{2,})\s/.exec(cells[1] ?? '')?.[1]
76    if (nr) ticks.set(nr, cells.slice(2, 2 + STEPS.length).map(cell => cell.includes('☑')))
77  }
78  return ticks
79}
80
81function depsOf(slice: Slice): string[] {
82  return slice.deps.split(/[,\s]+/).filter(dep => /^\d+$/.test(dep))
83}
84
85/** Main checkout and the worktree path of every branch, from `git worktree list --porcelain`. */
86function worktrees(porcelain: string): { main: string; byBranch: Map<string, string> } {
87  const byBranch = new Map<string, string>()
88  let main = ''
89  let path = ''
90  for (const line of porcelain.split(/\r?\n/)) {
91    if (line.startsWith('worktree ')) {
92      path = line.slice('worktree '.length)
93      if (!main) main = path
94    } else if (line.startsWith('branch refs/heads/')) {
95      byBranch.set(line.slice('branch refs/heads/'.length), path)
96    }
97  }
98  return { main, byBranch }
99}
100
101async function openRound($: EngineInterface, root: string): Promise<string | null> {
102  const files = await $.fs.list(`${root}/work/rounds`).catch(() => [])
103  for (const file of files.filter(entry => entry.name.endsWith('.md')).sort((a, b) => a.name.localeCompare(b.name))) {
104    const text = await readText($, `${root}/work/rounds/${file.name}`)
105    if (text?.split(/\r?\n/, 1)[0]?.trim() === 'Status: open') return /^R\d+/.exec(file.name)?.[0] ?? file.name
106  }
107  return null
108}
109
110/** The desktop app's sessions titled "Slice NN …", those working in this repository before the others; `isHere` says one works here. */
111function sessionsBySlice(entries: SessionEntry[], root: string): Map<string, { session: SliceSession; isHere: boolean }> {
112  const found = new Map<string, { session: SliceSession; isHere: boolean }>()
113  for (const entry of entries) {
114    const nr = /^Slice (\d{2,})\b/.exec(entry.title ?? '')?.[1]
115    if (!nr || !entry.link || !entry.sessionId || entry.isArchived) continue
116    const isHere = samePath(entry.cwd ?? '', root)
117    const known = found.get(nr)
118    if (!known || (isHere && !known.isHere)) {
119      const session = { id: entry.sessionId, title: entry.title ?? '', link: entry.link, isRunning: entry.isRunning === true }
120      found.set(nr, { session, isHere })
121    }
122  }
123  return found
124}
125
126/** Lists the app's sessions at most every 30 s (every refresh while a start is pending), and only where the permission check allows it without asking. */
127async function appSessions($: EngineInterface, now: number): Promise<SessionEntry[]> {
128  if (now - sessionCache.at < SESSIONS_MS) return sessionCache.entries
129  sessionCache = { at: now, entries: sessionCache.entries }
130  try {
131    const input = { limit: 50 }
132    const { decision } = await $.tool.check({ tool: LIST_SESSIONS, input })
133    if (decision !== 'allow') return sessionCache.entries
134    const called = (await $.tool.call({ tool: LIST_SESSIONS, ...input })) as { text?: string; result?: unknown }
135    const content = Array.isArray(called.result) ? (called.result as { text?: string }[]).find(part => typeof part.text === 'string')?.text : undefined
136    const entries = JSON.parse(called.text ?? content ?? '[]') as SessionEntry[]
137    sessionCache = { at: now, entries: Array.isArray(entries) ? entries : [] }
138  } catch (error) {
139    $.ui.log(`slice-board: sessions not listed: ${String(error)}`, { to: 'debug' })
140  }
141  return sessionCache.entries
142}
143
144async function collect($: EngineInterface, cwd: string): Promise<Board | null> {
145  const { main: root, byBranch } = worktrees(await git($, cwd, ['worktree', 'list', '--porcelain']))
146  if (!root) return null
147  const slicesText = await readText($, `${root}/work/_slices.json`)
148  if (!slicesText) return null
149  const now = await $.clock.now()
150  const conventions = (await readText($, `${root}/work/conventions.md`)) ?? ''
151  const integration = /(?:Integrationsbranch|Integration branch)[^`\n]*`([^`]+)`/i.exec(conventions)?.[1] ?? 'main'
152  const round = await openRound($, root)
153  if (!round) return { root, round: '–', integration, rows: [], updatedAt: now, problem: 'No open round.' }
154
155  const slices = (JSON.parse(slicesText) as Slice[]).filter(slice => slice.round === round)
156  const mainTicks = checklistTicks(await readText($, `${root}/work/checklist.md`))
157  const isDone = (nr: string) => (mainTicks.get(nr) ?? []).length === STEPS.length && mainTicks.get(nr)!.every(Boolean)
158  const branches = new Set((await git($, root, ['branch', '--list', 'slice-*', '--format=%(refname:short)'])).split(/\r?\n/).filter(Boolean))
159  const sessions = sessionsBySlice(await appSessions($, now), root)
160
161  const rows: SliceRow[] = []
162  for (const slice of slices) {
163    const branch = `slice-${slice.nr}`
164    const tree = byBranch.get(branch)
165    const done = isDone(slice.nr)
166    const hasBranch = !done && (branches.has(branch) || byBranch.has(branch))
167    let ticks = mainTicks.get(slice.nr) ?? STEPS.map(() => false)
168    let ahead = 0
169    let hasPlan = false
170    if (hasBranch) {
171      if (tree) ticks = checklistTicks(await readText($, `${tree}/work/checklist.md`)).get(slice.nr) ?? ticks
172      ahead = Number(await git($, root, ['rev-list', '--count', `${integration}..${branch}`])) || 0
173      const plans = await $.fs.list(`${tree ?? root}/work/plans`).catch(() => [])
174      hasPlan = plans.some(entry => entry.name.endsWith(`-slice-${slice.nr}.md`))
175    }
176    const waitingFor = depsOf(slice).filter(dep => !isDone(dep))
177    // A session of this repository titled for the slice takes its slot in every pane, also before it has created its branch.
178    const found = sessions.get(slice.nr)
179    const isClaimed = !done && found?.isHere === true
180    const state: SliceState = done ? 'done' : hasBranch || isClaimed || ticks.some(Boolean) ? 'active' : waitingFor.length ? 'blocked' : 'ready'
181    const session = state === 'active' ? (found?.session ?? null) : null
182    rows.push({ nr: slice.nr, slug: slice.slug, name: slice.name, state, ticks, waitingFor, ahead, hasPlan, session })
183  }
184  return { root, round, integration, rows, updatedAt: now, problem: null }
185}
186
187/** What a slice in progress is doing, from its session and its checklist ticks. */
188function phase(row: SliceRow): { label: string; color: string } {
189  if (row.session?.isRunning) return { label: 'working', color: 'claude' }
190  if (row.hasPlan && !row.ticks[0]) return { label: 'awaiting approval', color: 'warning' }
191  if (row.ticks.every(Boolean)) return { label: 'ready to merge', color: 'success' }
192  return { label: row.session ? 'idle' : 'no session', color: 'inactive' }
193}
194
195/** How far a slice is: its ticked steps, half a step more while its session works. */
196function progress(row: SliceRow): number {
197  const ticked = row.ticks.filter(Boolean).length
198  return Math.min(1, (ticked + (row.session?.isRunning && ticked < STEPS.length ? 0.5 : 0)) / STEPS.length)
199}
200
201/** A toast once when a slice becomes ready to start or its plan starts waiting for approval; nothing on the first refresh. */
202function announce($: EngineInterface, data: Board | null) {
203  if (!data) return
204  const ready = new Set(data.rows.filter(row => row.state === 'ready').map(row => row.nr))
205  const approval = new Set(data.rows.filter(row => row.state === 'active' && phase(row).label === 'awaiting approval').map(row => row.nr))
206  if (announced) {
207    const newlyReady = [...ready].filter(nr => !announced!.ready.has(nr))
208    if (newlyReady.length) $.ui.toast(`Ready to start: slice ${newlyReady.join(', ')}`)
209    for (const nr of approval) if (!announced.approval.has(nr)) $.ui.toast(`Slice ${nr}: plan awaits your approval`)
210  }
211  announced = { ready, approval }
212}
213
214/** Whether a refresh runs or waits: never two at once, none while the pane is closed. */
215let isRefreshing = false
216let isPaneOpen = false
217let pending: ReturnType<EngineInterface['clock']['after']> | undefined
218
219async function refresh($: EngineInterface, cwd: string) {
220  if (isRefreshing) return
221  isRefreshing = true
222  try {
223    const next = await collect($, cwd)
224    announce($, next)
225    await update($, board, () => next)
226  } catch (error) {
227    $.ui.log(`slice-board: ${String(error)}`, { to: 'debug' })
228  } finally {
229    isRefreshing = false
230  }
231}
232
233/** Waits 10 s, refreshes, and waits again, while the pane is open; closing the pane cancels the wait. */
234function scheduleRefresh($: EngineInterface) {
235  if (pending || !isPaneOpen) return
236  pending = $.clock.after(REFRESH_MS, () => {
237    pending = undefined
238    if (isPaneOpen) void refresh($, paneCwd).then(() => scheduleRefresh($))
239  })
240}
241
242/** Brings a session to the front: the app's window tool where it answers, else the session's `claude://` link through the system. */
243async function openSession($: EngineInterface, session: SliceSession) {
244  try {
245    const shown = (await $.tool.call({ tool: OPEN_SESSION, session_id: session.id, target: 'focus' })) as { deny?: string; isError?: boolean }
246    if (!shown.deny && !shown.isError) return
247  } catch {
248    // fall through to the link
249  }
250  for (const argv of [['explorer.exe', session.link], ['open', session.link], ['xdg-open', session.link]]) {
251    const opened = await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null)
252    if (opened) return
253  }
254  $.ui.toast(`Open "${session.title}" from the sidebar.`)
255}
256
257/**
258 * Puts a request into the person's prompt box to start one session per slice; Enter makes it their message and Claude
259 * starts the sessions with the session's own permission check. A start straight from the pane has no conversation
260 * behind it, and the auto mode classifier refuses it.
261 */
262async function requestStart($: EngineInterface, round: string, rows: SliceRow[]) {
263  const list = rows.map(row => `${row.nr} ${row.name}`).join('; ')
264  const text =
265    `Start one separate session per slice of round ${round} for: ${list}. ` +
266    `Each in this repository's main checkout without an app worktree, on the slice's "model" from work/_slices.json ` +
267    `(never this session's model), titled "Slice NN – <name> (${round})", ` +
268    `briefed with "/slice NN parallel auto" (the session goes on with its plan's assumptions and stops only on an open decision), ` +
269    `the slice's goal, scope, not-building list and acceptance cases from work/_slices.json, ` +
270    `the parallel rules (own branch and worktree, never push) and work/session-hints.md. ` +
271    `Each session ends with SLICE-READY: NN; the squash happens through the merge queue in the main checkout ` +
272    `(slice-loop --merge, or the running loop), never in the session itself.`
273  const box = await $.prompt.read()
274  const { isFilled } = await $.prompt.fill(box.text.trim() ? { text: `\n${text}`, mode: 'append' } : { text })
275  $.ui.toast(isFilled ? 'Start request is in the prompt box: Enter sends it.' : 'The prompt box is busy: close the dialog and press Start again.')
276}
277
278/** The session's folder; its refresh runs only after the pane was asked for. */
279let paneCwd = ''
280
281/** Opens the pane in this session and starts its refresh; sessions that never ask run no git at all. */
282async function showPane($: EngineInterface) {
283  paneCwd ||= await $.session.cwd()
284  await update($, isWanted, () => true)
285  sessionCache = { at: 0, entries: sessionCache.entries }
286  isPaneOpen = true
287  await refresh($, paneCwd)
288  scheduleRefresh($)
289  await $.ui.open({ id: PANE, title: TITLE })
290}
291
292export const register: Register = on => {
293  on('session.start', async ($, e, next) => {
294    const started = await next(e)
295    paneCwd = e.cwd
296    await $.command.register({ name: 'slices', description: 'Show the slices of the open round in a pane' })
297    if (await read($, isWanted)) await showPane($)
298    return started
299  })
300
301  // The pane belongs to the chat that runs the round, not to every session in the repository.
302  on('prompt.submit', async ($, e, next) => {
303    if (asksForPane(e.text, e.command)) await showPane($)
304    return next(e)
305  })
306
307  // A closed pane stops the refresh; /slices or a round command opens it again.
308  on('ui.close', async ($, e, next) => {
309    if (e.id === PANE) {
310      isPaneOpen = false
311      pending?.cancel()
312      pending = undefined
313    }
314    return next(e)
315  })
316
317  on('command.run', { command: 'slices' }, async $ => {
318    await showPane($)
319    return { text: 'Slice pane opened.' }
320  })
321
322  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
323    const elements = $.ui.resolve(e)
324    const { Box, Text, Button } = elements
325    // Air between rows and groups on surfaces that draw in pixels; the terminal keeps one line per row.
326    const air = e.surface === 'terminal' ? 0 : 1
327    const data = await read($, board)
328    const open = await read($, expanded)
329    if (!data) return <Text dimColor>No round here: work/_slices.json not found.</Text>
330    if (data.problem) return <Text dimColor>{data.problem}</Text>
331
332    const total = data.rows.length || 1
333    const rowsOf = (state: SliceState) => data.rows.filter(row => row.state === state)
334    const done = rowsOf('done')
335    const ready = rowsOf('ready')
336    const slots = Math.max(0, MAX_PARALLEL - rowsOf('active').length)
337    const startable = ready.slice(0, slots)
338    const percentOf = (count: number) => `${Math.round((count / total) * 100)} %`
339    const toggle = (state: SliceState) => update($, expanded, current => ({ ...current, [state]: !current[state] }))
340    const start = (rows: SliceRow[]) => requestStart($, data.round, rows)
341
342    /** Count and share, right-aligned in two fixed columns like the context pane's legend. */
343    const columns = (count: number) => (
344      <Box flexShrink={0}>
345        <Box width={5} justifyContent="flex-end">
346          <Text color="subtle">
347            {count}
348          </Text>
349        </Box>
350        <Box width={7} justifyContent="flex-end">
351          <Text color="subtle">
352            {percentOf(count)}
353          </Text>
354        </Box>
355      </Box>
356    )
357
358    /** One slice under its group: what it is, and on the right what it waits for or does. */
359    const sliceRow = (row: SliceRow) => {
360      const now = phase(row)
361      const step = row.ticks.every(Boolean) ? 'merge' : STEPS[row.ticks.findIndex(tick => !tick)]
362      // Every ready slice can be started while a slot is free; the person picks which.
363      const canStart = row.state === 'ready' && slots > 0
364      const right =
365        row.state === 'active' ? (
366          <Box flexShrink={0} alignItems="center" gap={1}>
367            <Text color="subtle">
368              {step} · {Math.round(progress(row) * 100)} %
369            </Text>
370            {row.session && <Button key={`open-${row.nr}`} plain label={'↗'} onPress={() => openSession($, row.session!)} />}
371          </Box>
372        ) : row.state === 'ready' ? (
373          <Box flexShrink={0}>
374            {canStart && <Button key={`start-${row.nr}`} plain label={'▶'} onPress={() => start([row])} />}
375          </Box>
376        ) : row.state === 'blocked' ? (
377          <Text color="subtle">
378            {'←'} {row.waitingFor.join(', ')}
379          </Text>
380        ) : null
381      const isDim = row.state === 'done' || row.state === 'blocked' || (row.state === 'ready' && !canStart)
382      const left = (
383        <Text wrap="truncate-end" dimColor={isDim}>
384          {row.state === 'active' && <Text color={now.color}>{'\u25cf'} </Text>}
385          <Text color="subtle">{row.nr} </Text>
386          {row.state === 'ready' ? row.name : row.slug}
387        </Text>
388      )
389      return (
390        <Box key={`row-${row.nr}`} flexDirection="column" paddingLeft={4} hover={{ backgroundColor: 'rgba(127,127,127,0.08)' }}>
391          <Box justifyContent="space-between" alignItems="center" gap={1}>
392            {/* minWidth 0: without it a flex item never shrinks below its text, the name pushes the buttons out of the pane. */}
393            <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
394              {left}
395            </Box>
396            {right}
397          </Box>
398        </Box>
399      )
400    }
401
402    return (
403      <Box flexDirection="column">
404        <Box justifyContent="space-between">
405          <Text>Round {data.round}</Text>
406          <Text color="subtle">
407            {done.length} / {data.rows.length} done
408          </Text>
409        </Box>
410        <Box marginTop={1} marginBottom={1}>
411          {GROUPS.filter(group => rowsOf(group.state).length > 0).map(group => {
412            const share = rowsOf(group.state).length / total
413            return (
414              <Box key={`bar-${group.state}`} width={0} flexGrow={rowsOf(group.state).length} backgroundColor={group.fill} justifyContent="center" overflow="hidden">
415                <Text color="#ffffff" wrap="truncate-end">
416                  {share >= 0.08 ? percentOf(rowsOf(group.state).length) : ' '}
417                </Text>
418              </Box>
419            )
420          })}
421        </Box>
422
423        {/* Start above the groups, so it stays in view however long the lists get. */}
424        {ready.length > 0 && (
425          <Box justifyContent="space-between" alignItems="center" marginBottom={1}>
426            <Text color="subtle">{slots > 0 ? `${slots} of ${MAX_PARALLEL} slots free` : `All ${MAX_PARALLEL} slots in use`}</Text>
427            {startable.length > 0 && <Button key="start-ready" label={`Start ${startable.length}`} onPress={() => start(startable)} />}
428          </Box>
429        )}
430
431        {GROUPS.map(group => {
432          const rows = rowsOf(group.state)
433          const isOpen = open[group.state] && rows.length > 0
434          return (
435            <Box key={`group-${group.state}`} flexDirection="column" marginTop={air} gap={air}>
436              <Box justifyContent="space-between" alignItems="center">
437                <Box alignItems="center" gap={1}>
438                  <Text color={e.surface === 'terminal' ? group.cell : group.fill}>{'■'}</Text>
439                  <Button key={`toggle-${group.state}`} plain label={`${isOpen ? '▾' : '›'} ${group.label}`} onPress={() => toggle(group.state)} />
440                </Box>
441                {columns(rows.length)}
442              </Box>
443              {isOpen && rows.map(sliceRow)}
444            </Box>
445          )
446        })}
447
448      </Box>
449    )
450  })
451}
452
types/index.d.ts 41 lines
1export type SliceState = 'done' | 'active' | 'ready' | 'blocked'
2
3/** The Claude session that works on a slice, matched by its title "Slice NN …". */
4export type SliceSession = { id: string; title: string; link: string; isRunning: boolean }
5
6export type SliceRow = {
7  nr: string
8  slug: string
9  name: string
10  state: SliceState
11  /** Ticks of the five checklist columns (Plan, Tests red, Green, Checks, Commit), from the slice's worktree when it has one. */
12  ticks: boolean[]
13  /** Dependencies that are not done yet. */
14  waitingFor: string[]
15  /** Commits on slice-NN ahead of the integration branch. */
16  ahead: number
17  hasPlan: boolean
18  session: SliceSession | null
19}
20
21export type Board = {
22  root: string
23  round: string
24  integration: string
25  rows: SliceRow[]
26  updatedAt: number
27  problem: string | null
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    'slice-board': {
33      board: Board | null
34      /** Which groups of the pane are unfolded. */
35      expanded: Record<SliceState, boolean>
36      /** Whether this session asked for the pane. */
37      isWanted: boolean
38    }
39  }
40}
41