SLOPSHOPPER

status-band

Shows the stage, breadcrumb, a status-colored sprint bar, timing and the current story above the prompt; its crumbs and count open the epic, sprint and stories

newpanebandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-band
│ ┃ status-band ✕ › fix the failing auth test and add an audit log call │ ┃ No sprint file. │ ⏺ 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 │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · status-band
No sprint file.
README

Nexus Reborn

A single-player browser game inspired by Syndicate. As the Operations Director of Nexus Global, inspect the World Network, fund research, accept contracts, and deploy one to four operatives into real-time isometric missions.

React 19 and Vite drive the interface; react-three-fiber and three.js drive the mission scene; Zustand holds campaign and HUD state. Runtime visual assets are generated in code.

Run locally

Prerequisites

  • Node.js 24+ and npm. The repository does not pin a Node version; dependency versions are locked in package-lock.json. Use an even-numbered Node release compatible with the locked toolchain.
  • A desktop browser with hardware-accelerated graphics, keyboard and mouse, and a viewport of at least 1280×720. Mobile and touch are outside the game's scope.
  • No account, API credentials, or backend service is required for local play.

From the repository root:

npm ci
npm run dev -- --strictPort

Open the URL printed by Vite, normally http://localhost:4200. vite.config.ts uses PORT if set; --strictPort makes an occupied port an explicit error instead of silently choosing another. Stop the server with Ctrl+C when finished.

Start a New Operation, select an unlocked contract on the World Network, then proceed through Brief → Assembly → mission. The mission pause menu lists the current bindings; the complete control reference is in the GDD.

Browser and save expectations

  • The mission uses three.js WebGPURenderer, whose initialization automatically falls back to WebGL2 when needed. The console prints [scene] renderer backend: ... and the resolved quality tier after initialization. A menu loading successfully does not verify mission rendering.
  • Retained architecture QA covers WebGPU in the Codex in-app browser, not a browser/version compatibility matrix. WebGL2 fallback is implemented but was not exercised in that report. Record the actual backend when reporting a rendering problem.
  • First deployment can pause while graphics pipelines initialize. If deployment does not complete, capture the console error, browser/version, GPU, backend if reported, and quality setting rather than assuming a particular cause.
  • Campaign data is local to the browser origin. Reloading does not resume a mission in progress. New Operation erases the current campaign, but preserves settings. Use a separate browser profile for destructive QA; changing host or port also changes the storage origin.

Verify a change

Standalone asset viewer

Run npm run dev:assets -- --strictPort to open /tools/asset-viewer.html with the procedural Agent already loaded. The viewer and mission use the same builder in src/scene/unitModel.ts. Orbit, zoom, and pan with the mouse; use the inspector for camera presets, operative accents, weapon and selection markers, wireframe, turntable, grid, and exposure. Geometry statistics describe the model without its selection markers. The viewer does not initialize a campaign or mission.

The page is also included in npm run build and can be opened at the same path with npm run preview -- --port 4200 --strictPort. Stop the server after use.

Automated checks

npm run lint
npm run test
npm run build

These are the required automated checks. build typechecks TypeScript and creates the production bundle in dist/; tests live beside their modules.

To inspect that production bundle locally:

npm run preview -- --port 4200 --strictPort

Stop the development server first if it occupies the same port. Preview is a local check, not a deployment service. For rendering, screen flow, input, audio, or persistence changes, also run the click-through at 1280×720 and record actual coverage using the QA record format. Automated checks alone do not establish a browser pass.

Stop every dev/preview server you start. On macOS, confirm the QA port is clear with:

lsof -nP -iTCP:4200 -sTCP:LISTEN

No listener output means the port is clear. If you used another port, check that one too; do not stop unrelated processes.

Documentation map

NeedReadAuthority / scope
Understand the game and its rulesGame designLiving specification, including acceptance criteria and explicitly pending targets
Use the right namesDomain glossaryCanonical terminology, not a replacement for the rules
Understand settled decisionsADRsRationale and consequences; reopen decisions explicitly
Change code safelyEngineering guidanceModule boundaries, state lifetimes, rendering constraints, verification and contribution conventions
Exercise the player flowClick-throughManual procedure; not evidence that a check has passed
Record or inspect verificationQA recordsReproducible record format and historical evidence index
Work on district renderingCity architectureShared geometry/material kit, visibility behavior and scoped verification
Work on sound assetsAudio sources and preparationCredits, checksums, rebuilding and playback checks
Work with issuesIssue trackerGitHub workflow and issue relationships

The styled GDD is a dated snapshot, not the living specification. Historical QA describes the revision and coverage recorded there, not a current whole-game pass. Code establishes implemented behavior; when it disagrees with the living specification, resolve the discrepancy rather than copying stale rules forward.

Source 2 files
hooks/register.tsx 583 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode } from 'claude-code'
3
4import type { Band, FileRef, PaneView, Progress, Sprint, Story } from '../types'
5
6// The band draws .claude/statusline.sh's stage and breadcrumb, less its first
7// two segments (context tokens, model), so the script stays the one source of
8// those. Sprint progress, timing and the current story come from
9// sprint-status.yaml; the next step of a finished sprint from the session state.
10const SCRIPT = '.claude/statusline.sh'
11const SPRINT_YAML = 'production/sprint-status.yaml'
12const SESSION_STATE = 'production/session-state/active.md'
13const EPICS = 'production/epics'
14const DROPPED_SEGMENTS = 2
15const REFRESH_MS = 60_000
16const DAY_MS = 86_400_000
17const BAR_CELLS = 10
18// Below WIDE the epic crumb and the timing go; below NARROW the rigor, the
19// bar and the next step go too.
20const WIDE = 100
21const NARROW = 70
22// A story the band names when the Task slot is empty, most urgent first.
23const CURRENT_STATUSES = ['in-progress', 'review']
24const PANE = 'status-band'
25const SEP = ' › '
26
27const band = atom({ plugin: 'status-band', key: 'band' } as const, null)
28const pane = atom({ plugin: 'status-band', key: 'pane' } as const, { kind: 'stories' } as PaneView)
29
30export type SprintStory = { id: string; name: string; file: string; status: string }
31
32export type Status = { stage: string; rigor: string | null; crumbs: string[] }
33
34export const parseStatus = (output: string): Status | null => {
35  const segments = output.trim().split(' | ').slice(DROPPED_SEGMENTS)
36  const [head = '', ...rest] = segments
37  const [stage = '', ...rigor] = head.split(' · ')
38
39  if (stage.trim() === '') {
40    return null
41  }
42
43  const crumbs = rest.join(' | ').split(' > ').map(c => c.trim()).filter(c => c !== '')
44
45  return { stage: stage.trim(), rigor: rigor.length > 0 ? rigor.join(' · ') : null, crumbs }
46}
47
48const unquote = (value: string): string => value.trim().replace(/^"(.*)"$/, '$1')
49
50// sprint-status.yaml is written by /sprint-plan in one fixed shape, so a line
51// parse of the story keys is enough and keeps the mod free of a yaml library.
52export const parseSprint = (yaml: string): SprintStory[] => {
53  const stories: SprintStory[] = []
54
55  for (const line of yaml.split('\n')) {
56    const match = /^\s*(-\s+)?(id|name|file|status):\s*(.*)$/.exec(line)
57
58    if (match === null) {
59      continue
60    }
61
62    const [, dash, key, value = ''] = match
63    const last = stories.at(-1)
64
65    if (dash !== undefined && key === 'id') {
66      stories.push({ id: unquote(value), name: '', file: '', status: '' })
67    } else if (dash === undefined && last !== undefined && key !== 'id') {
68      last[key as 'name' | 'file' | 'status'] = unquote(value)
69    }
70  }
71
72  return stories
73}
74
75// The sprint's own keys sit unindented at the top of the same file.
76export const parseSprintHeader = (yaml: string): { goal: string | null; start: string | null; end: string | null } => {
77  const key = (name: string) => {
78    const match = new RegExp(`^${name}:\\s*(.*)$`, 'm').exec(yaml)
79    const value = match === null ? '' : unquote(match[1] ?? '')
80
81    return value === '' ? null : value
82  }
83
84  return { goal: key('goal'), start: key('start'), end: key('end') }
85}
86
87export const progressOf = (stories: SprintStory[]): Progress | null => {
88  if (stories.length === 0) {
89    return null
90  }
91
92  const count = (status: string) => stories.filter(s => s.status === status).length
93
94  return {
95    done: count('done'),
96    review: count('review'),
97    inProgress: count('in-progress'),
98    blocked: count('blocked'),
99    total: stories.length,
100  }
101}
102
103export const isComplete = (progress: Band['progress']): boolean =>
104  typeof progress === 'object' && progress !== null && progress.done === progress.total
105
106// Whole days since the epoch, in the session's own time zone.
107export const dayNumber = (ms: number): number =>
108  Math.floor((ms - new Date(ms).getTimezoneOffset() * 60_000) / DAY_MS)
109
110const dayOf = (iso: string | null): number | null => {
111  const match = iso === null ? null : /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso)
112
113  return match === null ? null : Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) / DAY_MS
114}
115
116export const timingOf = (start: string | null, end: string | null, today: number): Sprint['timing'] => {
117  const first = dayOf(start)
118  const last = dayOf(end)
119
120  if (first !== null && today < first) return { kind: 'starts', days: first - today }
121  if (last !== null && today <= last) return { kind: 'left', days: last - today }
122  if (last !== null) return { kind: 'over', days: today - last }
123
124  return null
125}
126
127export const timingText = (timing: NonNullable<Sprint['timing']>): string => {
128  if (timing.kind === 'starts') return `starts in ${timing.days}d`
129  if (timing.kind === 'over') return `${timing.days}d over`
130
131  return timing.days === 0 ? 'last day' : `${timing.days}d left`
132}
133
134// The first slash command of the **Next step:** line, with its one argument.
135export const nextStepOf = (state: string): string | null => {
136  const line = /^\*\*Next step:\*\*\s*(.*)$/m.exec(state)?.[1] ?? ''
137  const match = /(?:^|\s)(\/[a-z][\w-]*)(?:\s+(?!then\b|and\b|or\b)([a-z][\w-]*))?/.exec(line)
138
139  if (match === null) {
140    return null
141  }
142
143  return match[2] === undefined ? (match[1] ?? null) : `${match[1]} ${match[2]}`
144}
145
146const hrefOf = (path: string) => encodeURI(`file://${path}`)
147
148const markdownFile = (path: string, title: string): FileRef => ({ path, title, format: 'markdown' })
149
150// The Task slot (a third crumb) wins when it names a sprint story: that crumb
151// becomes the link. With no Task, the first in-progress or review story is.
152export const currentStory = (
153  stories: SprintStory[],
154  crumbs: string[],
155  root: string,
156): { crumbs: string[]; story: Story | null } => {
157  const byId = new Map(stories.map(s => [s.id, s]))
158  const idOf = (crumb: string) => crumb.split(' ')[0] ?? ''
159  const storyOf = (label: string, task: SprintStory): Story => {
160    const path = `${root}/${task.file}`
161
162    return { label, href: hrefOf(path), file: markdownFile(path, task.id) }
163  }
164  const label = crumbs.find(c => byId.has(idOf(c)))
165  const task = label === undefined ? undefined : byId.get(idOf(label))
166
167  if (label !== undefined && task !== undefined) {
168    return { crumbs: crumbs.filter(c => c !== label), story: storyOf(label, task) }
169  }
170
171  if (crumbs.length >= 3) {
172    return { crumbs, story: null }
173  }
174
175  for (const status of CURRENT_STATUSES) {
176    const found = stories.find(s => s.status === status)
177
178    if (found !== undefined) {
179      return { crumbs, story: storyOf(`${found.id} ${found.name}`, found) }
180    }
181  }
182
183  return { crumbs, story: null }
184}
185
186export const sprintOf = (yaml: string, stories: SprintStory[], root: string, today: number): Sprint => {
187  const { goal, start, end } = parseSprintHeader(yaml)
188
189  return {
190    file: { path: `${root}/${SPRINT_YAML}`, title: 'sprint-status.yaml', format: 'yaml' },
191    goal,
192    timing: timingOf(start, end, today),
193    rows: stories.map(s => ({
194      id: s.id,
195      name: s.name,
196      status: s.status,
197      file: markdownFile(`${root}/${s.file}`, s.id),
198    })),
199  }
200}
201
202export const slugOf = (crumb: string): string =>
203  crumb.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
204
205const isSprintCrumb = (crumb: string) => /^sprint\b/i.test(crumb)
206
207export type Layout = Band & { bar: boolean; timing: boolean }
208
209export const layout = (value: Band, columns: number): Layout => ({
210  ...value,
211  rigor: columns < NARROW ? null : value.rigor,
212  crumbs: columns < WIDE && value.crumbs.length > 1 ? value.crumbs.slice(1) : value.crumbs,
213  nextStep: columns < NARROW ? null : value.nextStep,
214  bar: columns >= NARROW,
215  timing: columns >= WIDE,
216})
217
218// Never a full bar short of done, never an empty one past zero.
219export const filledCells = ({ done, total }: Progress): number => {
220  const cells = Math.round((done / total) * BAR_CELLS)
221
222  if (done === total) return BAR_CELLS
223  if (done === 0) return 0
224
225  return Math.min(BAR_CELLS - 1, Math.max(1, cells))
226}
227
228export const SEGMENT_KINDS = ['done', 'review', 'inProgress', 'blocked'] as const
229
230export type SegmentKind = (typeof SEGMENT_KINDS)[number]
231
232// Cells per status for the terminal bar: done keeps filledCells' rule, every
233// other status with a story gets at least one cell, and an empty cell stays
234// while any story has not started.
235export const segments = (progress: Progress): Record<SegmentKind, number> => {
236  const cells: Record<SegmentKind, number> = { done: filledCells(progress), review: 0, inProgress: 0, blocked: 0 }
237  const others = ['inProgress', 'review', 'blocked'] as const
238
239  for (const kind of others) {
240    if (progress[kind] > 0) {
241      cells[kind] = Math.max(1, Math.round((progress[kind] / progress.total) * BAR_CELLS))
242    }
243  }
244
245  const notStarted = progress.total - SEGMENT_KINDS.reduce((sum, kind) => sum + progress[kind], 0)
246  const room = notStarted > 0 ? BAR_CELLS - 1 : BAR_CELLS
247  const sum = () => SEGMENT_KINDS.reduce((total, kind) => total + cells[kind], 0)
248
249  if (notStarted === 0 && progress.done < progress.total) {
250    while (sum() < BAR_CELLS) {
251      const largest = others.reduce((a, b) => (progress[b] > progress[a] ? b : a))
252      cells[largest] += 1
253    }
254  }
255
256  while (sum() > room) {
257    const largest = SEGMENT_KINDS.filter(kind => cells[kind] > 1).sort((a, b) => cells[b] - cells[a])[0]
258
259    if (largest === undefined) break
260    cells[largest] -= 1
261  }
262
263  return cells
264}
265
266const THEME_COLORS: Record<SegmentKind, string> = {
267  done: 'success',
268  review: 'warning',
269  inProgress: 'suggestion',
270  blocked: 'error',
271}
272
273// The desktop bar: raw colors, since an Svg is drawn outside the theme.
274const SVG_COLORS: Record<SegmentKind, string> = {
275  done: '#4eba65',
276  review: '#e5a53b',
277  inProgress: '#7c9cf5',
278  blocked: '#e5484d',
279}
280
281const escapeXml = (text: string) =>
282  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
283
284// `title` is the hover tooltip: an interactive Svg draws its <title> natively.
285export const barSvg = (progress: Progress, title: string): string => {
286  const width = 100
287  const height = 6
288  let x = 0
289  const rects = SEGMENT_KINDS.map(kind => {
290    const w = (progress[kind] / progress.total) * width
291    const rect = w > 0 ? `<rect x="${x.toFixed(2)}" width="${w.toFixed(2)}" height="${height}" fill="${SVG_COLORS[kind]}"/>` : ''
292    x += w
293
294    return rect
295  }).join('')
296
297  return (
298    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
299    `<title>${escapeXml(title)}</title>` +
300    `<clipPath id="r"><rect width="${width}" height="${height}" rx="3"/></clipPath>` +
301    `<g clip-path="url(#r)"><rect width="${width}" height="${height}" fill="#8888884d"/>${rects}</g></svg>`
302  )
303}
304
305export const progressAlt = (progress: Progress): string => {
306  const extra = [
307    progress.review > 0 ? `${progress.review} in review` : '',
308    progress.inProgress > 0 ? `${progress.inProgress} in progress` : '',
309    progress.blocked > 0 ? `${progress.blocked} blocked` : '',
310  ].filter(part => part !== '')
311
312  return [`${progress.done} of ${progress.total} stories done`, ...extra].join(', ')
313}
314
315const STATUS_MARKS: Record<string, { mark: string; color: string }> = {
316  done: { mark: '✓', color: 'success' },
317  review: { mark: '◐', color: 'warning' },
318  'in-progress': { mark: '▶', color: 'suggestion' },
319  blocked: { mark: '✕', color: 'error' },
320}
321
322// undefined: the file is absent; null: it is there but could not be read.
323const readText = async ($: EngineInterface, path: string): Promise<string | null | undefined> => {
324  try {
325    if (!(await $.fs.exists(path))) {
326      return undefined
327    }
328
329    const text = await $.fs.read(path)
330
331    return typeof text === 'string' ? text : null
332  } catch {
333    return null
334  }
335}
336
337// A run that cannot start or outlives its timeout rejects; it counts as a
338// failed run, so the band hands the draw back instead of the hook throwing.
339const runScript = async ($: EngineInterface): Promise<Status | null> => {
340  try {
341    const { exitCode, stdout } = await $.process.run(['bash', SCRIPT], {
342      stdin: JSON.stringify({ model: { display_name: '' } }),
343      timeoutMs: 10_000,
344    })
345
346    return exitCode === 0 ? parseStatus(stdout) : null
347  } catch {
348    return null
349  }
350}
351
352// The first crumb of two or more is the epic when its folder has an EPIC.md.
353const epicOf = async ($: EngineInterface, root: string, crumbs: string[]): Promise<Band['epic']> => {
354  const crumb = crumbs.length >= 2 ? crumbs[0] : undefined
355
356  if (crumb === undefined) {
357    return null
358  }
359
360  const path = `${root}/${EPICS}/${slugOf(crumb)}/EPIC.md`
361  const exists = await $.fs.exists(path).catch(() => false)
362
363  return exists ? { crumb, file: markdownFile(path, crumb) } : null
364}
365
366const refresh = async ($: EngineInterface, root: string) => {
367  const status = await runScript($)
368  let next: Band | null = null
369
370  if (status !== null) {
371    const yaml = await readText($, `${root}/${SPRINT_YAML}`)
372    const stories = typeof yaml === 'string' ? parseSprint(yaml) : []
373    const { crumbs, story } = currentStory(stories, status.crumbs, root)
374    const progress = yaml === null ? 'unreadable' : progressOf(stories)
375    const today = dayNumber(await $.clock.now())
376    const sprint = typeof yaml === 'string' && stories.length > 0 ? sprintOf(yaml, stories, root, today) : null
377    const state = isComplete(progress) ? await readText($, `${root}/${SESSION_STATE}`) : undefined
378
379    next = {
380      ...status,
381      crumbs,
382      epic: await epicOf($, root, crumbs),
383      progress,
384      sprint,
385      story,
386      nextStep: typeof state === 'string' ? nextStepOf(state) : null,
387    }
388  }
389
390  const before = await read($, band)
391
392  if (JSON.stringify(before) !== JSON.stringify(next)) {
393    await update($, band, () => next)
394  }
395}
396
397const openFile = async ($: EngineInterface, file: FileRef) => {
398  const text = await readText($, file.path)
399
400  const view: PaneView = { kind: 'file', file, text: typeof text === 'string' ? text : null }
401
402  await update($, pane, () => view)
403  await $.ui.open({ id: PANE, title: file.title })
404}
405
406const openStories = async ($: EngineInterface) => {
407  const view: PaneView = { kind: 'stories' }
408
409  await update($, pane, () => view)
410  await $.ui.open({ id: PANE, title: 'Sprint stories' })
411}
412
413export const register: Register = on => {
414  let root = '.'
415
416  on('session.start', async ($, e, next) => {
417    root = e.cwd
418    // The interval first, so a first refresh that fails still leaves it running.
419    $.clock.every(REFRESH_MS, () => refresh($, root))
420    await refresh($, root)
421
422    return next(e)
423  })
424
425  on('turn.complete', async ($, e, next) => {
426    await refresh($, root)
427
428    return next(e)
429  })
430
431  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
432    const value = await read($, band)
433
434    if (e.props.hasSurvey || value === null) {
435      return next(e)
436    }
437
438    const { Box, Button, Link, Text } = $.ui.resolve(e)
439    const isTerminal = e.surface === 'terminal'
440    const shown = layout(value, e.props.bodyColumns)
441    const parts: RenderNode[] = []
442    const sep = (key: string) => <Text key={key} dimColor>{SEP}</Text>
443    // The terminal opens a file: link in the person's editor; the desktop opens
444    // no file: link, so there the crumb is a Button that shows the file in a pane.
445    const opener = (key: string, label: string, file: FileRef) =>
446      isTerminal
447        ? <Text key={key}><Link href={hrefOf(file.path)} label={label} /></Text>
448        : <Button key={key} plain label={label} onPress={() => void openFile($, file)} />
449
450    parts.push(<Text key="stage" bold>{shown.stage}</Text>)
451
452    if (shown.rigor !== null) {
453      parts.push(<Text key="rigor" dimColor>·{shown.rigor}</Text>)
454    }
455
456    shown.crumbs.forEach((crumb, i) => {
457      parts.push(sep(`crumb-sep-${i}`))
458
459      if (shown.epic !== null && crumb === shown.epic.crumb) {
460        parts.push(opener('epic', crumb, shown.epic.file))
461      } else if (shown.sprint !== null && isSprintCrumb(crumb)) {
462        parts.push(opener('sprint', crumb, shown.sprint.file))
463      } else {
464        parts.push(<Text key={`crumb-${i}`}>{crumb}</Text>)
465      }
466    })
467
468    if (shown.progress === 'unreadable') {
469      parts.push(<Text key="progress" dimColor>{'  sprint ?'}</Text>)
470    } else if (shown.progress !== null) {
471      const progress = shown.progress
472      const count = `${progress.done}/${progress.total}`
473
474      parts.push(<Text key="progress-gap">{'  '}</Text>)
475
476      if (isComplete(progress)) {
477        parts.push(
478          <Button key="progress" plain onPress={() => void openStories($)}>
479            <Text color="success">✓ complete</Text>
480            <Text dimColor>{` ${count}`}</Text>
481          </Button>,
482        )
483      } else if (shown.bar && isTerminal) {
484        const cells = segments(progress)
485        const empty = BAR_CELLS - SEGMENT_KINDS.reduce((sum, kind) => sum + cells[kind], 0)
486
487        parts.push(
488          <Button key="progress" plain onPress={() => void openStories($)}>
489            {SEGMENT_KINDS.filter(kind => cells[kind] > 0).map(kind => (
490              <Text color={THEME_COLORS[kind]}>{'▰'.repeat(cells[kind])}</Text>
491            ))}
492            {empty > 0 && <Text dimColor>{'▱'.repeat(empty)}</Text>}
493            {` ${count}`}
494          </Button>,
495        )
496      } else {
497        if (shown.bar && e.surface !== 'terminal') {
498          const { Svg } = $.ui.resolve(e)
499
500          const alt = progressAlt(progress)
501          const goal = shown.sprint?.goal
502          const title = goal === null || goal === undefined ? alt : `Goal: ${goal}\n${alt}`
503
504          parts.push(<Svg key="bar" source={barSvg(progress, title)} alt={alt} width={100} height={6} isInteractive />)
505          parts.push(<Text key="bar-gap">{' '}</Text>)
506        }
507
508        parts.push(<Button key="progress" plain label={count} onPress={() => void openStories($)} />)
509      }
510
511      if (progress.blocked > 0) {
512        parts.push(<Text key="blocked" color="error">{`  ${progress.blocked} blocked`}</Text>)
513      }
514
515      if (!isComplete(progress) && shown.timing && shown.sprint?.timing) {
516        const timing = shown.sprint.timing
517
518        parts.push(
519          timing.kind === 'over'
520            ? <Text key="timing" color="warning">{`  ${timingText(timing)}`}</Text>
521            : <Text key="timing" dimColor>{`  ${timingText(timing)}`}</Text>,
522        )
523      }
524
525      if (isComplete(progress) && shown.nextStep !== null) {
526        parts.push(sep('next-sep'))
527        parts.push(<Text key="next" dimColor>next </Text>)
528        parts.push(<Text key="next-step">{shown.nextStep}</Text>)
529      }
530    }
531
532    if (shown.story !== null) {
533      parts.push(sep('story-sep'))
534      parts.push(opener('story', shown.story.label, shown.story.file))
535    }
536
537    return <Box flexDirection="row" alignItems="center">{parts}</Box>
538  })
539
540  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
541    const { Box, Button, Code, Markdown, Text } = $.ui.resolve(e)
542    const view = await read($, pane)
543    const sprint = (await read($, band))?.sprint ?? null
544
545    if (view.kind === 'file') {
546      return (
547        <Box flexDirection="column">
548          {sprint !== null && (
549            <Button key="back" plain dimColor label="‹ Sprint stories" onPress={() => void openStories($)} />
550          )}
551          {view.text === null
552            ? <Text dimColor>{`Could not read ${view.file.path}`}</Text>
553            : view.file.format === 'yaml'
554              ? <Code source={view.text} language="yaml" />
555              : <Markdown text={view.text} />}
556        </Box>
557      )
558    }
559
560    if (sprint === null) {
561      return <Text dimColor>No sprint file.</Text>
562    }
563
564    return (
565      <Box flexDirection="column">
566        {sprint.goal !== null && <Text dimColor wrap="wrap">{sprint.goal}</Text>}
567        {sprint.rows.map(row => {
568          const mark = STATUS_MARKS[row.status] ?? { mark: '·', color: 'inactive' }
569
570          return (
571            <Button key={`story-${row.id}`} plain onPress={() => void openFile($, row.file)}>
572              <Text color={mark.color}>{`${mark.mark} `}</Text>
573              {`${row.id} ${row.name} `}
574              <Text dimColor>{row.status}</Text>
575            </Button>
576          )
577        })}
578        <Button key="sprint-file" plain dimColor label="Open sprint-status.yaml" onPress={() => void openFile($, sprint.file)} />
579      </Box>
580    )
581  })
582}
583
types/index.d.ts 46 lines
1// A file the band opens: absolute path, the pane's title, how the pane draws it.
2export type FileRef = { path: string; title: string; format: 'markdown' | 'yaml' }
3
4export type Story = { label: string; href: string; file: FileRef }
5
6export type Progress = {
7  done: number
8  review: number
9  inProgress: number
10  blocked: number
11  total: number
12}
13
14export type SprintRow = { id: string; name: string; status: string; file: FileRef }
15
16export type Sprint = {
17  file: FileRef
18  goal: string | null
19  // Whole days, from the yaml's start and end against today.
20  timing: { kind: 'starts'; days: number } | { kind: 'left'; days: number } | { kind: 'over'; days: number } | null
21  rows: SprintRow[]
22}
23
24export type Band = {
25  stage: string
26  rigor: string | null
27  crumbs: string[]
28  // The crumb that names the epic, with its EPIC.md when there is one.
29  epic: { crumb: string; file: FileRef } | null
30  // null: no sprint file; 'unreadable': the file is there but failed to read.
31  progress: Progress | 'unreadable' | null
32  sprint: Sprint | null
33  story: Story | null
34  // The first slash command of the session state's **Next step:** line.
35  nextStep: string | null
36}
37
38// What the band's pane shows: the sprint's stories, or one file.
39export type PaneView = { kind: 'stories' } | { kind: 'file'; file: FileRef; text: string | null }
40
41declare module 'claude-code' {
42  interface PluginState {
43    'status-band': { band: Band | null; pane: PaneView }
44  }
45}
46