SLOPSHOPPER

superpowers-statusbar

Live progress of a superpowers run: a band above the prompt, a side pane and notifications.

newpanebandguardcommandtool
v0.1.4no licenseupdated 2026-10-14AdrienGras/superpowers-statusbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · superpowers-statusbar
│ ┃ superpowers ✕ › fix the failing auth test and add an audit log call │ ┃ No superpowers run yet. │ ⏺ 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 │ │ › /sp-progress │ ⎿ superpowers-statusbar: Progress pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · superpowers
No superpowers run yet.
README

superpowers-statusbar

A Claude Code mod that shows the progress of a superpowers run: the current stage (brainstorming, plan, execution, final review, finishing), the plan's task, the implementation and review loop, and what is waiting for an answer from you.

Superpowers 7.0.0 is required: the mod recognises the scripts, the subagent dispatches and the ledger as that version writes them.

What the mod does

  • A band above the prompt, on one line. During execution:
  ⠋ exec SDD ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱ 7/15 · Task 7: Ledger parser · review · fix 2/5 · 42m

Before execution, the line gives the stage and its phase (● brainstorming · conversation, ● brainstorming · builder check, ● plan · drafting). While the run works, its mark is a spinner where the surface can animate one (terminal and desktop). When the run waits for an answer, the line becomes one highlighted block, WAITING · <reason> . When the width runs short, the band drops the elapsed time first, then the task title, then the bar. Outside a superpowers run, the band shows nothing. Its colours follow your Claude Code theme.

  • A pane in five blocks: the stages (with how long each finished one took, and the milestones of brainstorming and plan), the tasks (status, duration, fix rounds), the current task's phases one per line (passed, under way, still expected), the decisions and set-aside points taken from the ledger, and an estimate of the time left. An empty block is not shown.
  • Notifications when the run starts waiting: plan ready and execution mode to pick, implementer blocked or the 5-fix-round breaker tripped, finishing menu. Brainstorming questions and the spec approval do not notify.

The mod follows the run from Claude Code's events: tool calls (superpowers scripts, writes of the spec and the plan, subagent dispatches) and their results, turn ends, and the skills launched through the Skill tool. For the phases those events do not show (conversation, drafting and review of the spec or the plan, RED/GREEN/refactor), the model reports them through a tool the mod exposes, report_phase. It is guided by a short instruction placed at the start of the session and of each subagent (about 150 tokens each) and by the tool's description. For RED/GREEN the mod also infers them from the plan's run commands: the first file written in a task means red, the first run of a plan command after a write means green. The mod does not change the skills' text. Limits:

  • a task whose plan gives no run command only has the phases the model reports;
  • the mod does not read test results: green means a plan command was run, not that it passed;
  • refactor comes from the model only.

A skill launched by typing /superpowers:… is seen through the command.run hook; one the model launches is seen through its Skill tool call. The skill.prompt hook is no longer observed: the security plugin built into Claude Code bypasses it for user-level plugins, and it carries no agent id.

Installation

In a Claude Code session, at a terminal's prompt:

/plugin install superpowers-statusbar --marketplace AdrienGras/superpowers-statusbar

Answer y to add the marketplace, then pick the scope (user by default).

Development

To start a session with the mod loaded from a local copy of the repository:

claude --plugin-dir /path/to/superpowers-statusbar

Checks:

claude plugin test .
npm run typecheck
claude plugin validate .

The SP_STATUSBAR_TRACE environment variable, when it names a file, makes the mod write one JSON line per recognised action to it.

Tests

  • claude plugin test .: unit tests, on synthetic runs (tests/fixtures/).
  • npm run contract -- <superpowers folder>: checks that the markers the mod recognises exist in a superpowers version.
  • npm run local: runs the real plans and ledgers dropped in fixtures-local/ (gitignored) through the parser.
  • npm run e2e -- <superpowers folder> [sdd|direct|all] [npm|bats|all]: local e2e with a real claude -p. The second argument picks the mode, all by default. The last argument picks the fixture: e2e/fixture (a small Node library tested with node --test, npm) or e2e/fixture-bats (a small bash library tested with bats, bats); the default is all, both fixtures. The script copies the fixture (with its 3-task plan) to a temporary folder and has the plan executed there in SDD mode (subagent-driven-development), in inline mode (executing-plans) or both, with the mod and the given superpowers version loaded through --plugin-dir. The run is isolated from your configuration (--setting-sources project,local): none of your hooks, installed plugins or user CLAUDE.md; only the mod and the tested version are loaded. The run does not get every permission: acceptEdits (writes in the run's folder only) and a limited tool list (reading files, skills, subagents, the mod's tool, the scripts of the tested superpowers version, node --test, bats, git, npm test, a few shell text commands such as cat, grep or ls, and removing the plan's workspace); other Bash commands are refused. This list is not a security boundary: npm test and node --test run code the model writes, and git can act on other repositories and run commands through aliases. It guides a cooperative model, nothing more: treat the run as unsandboxed, and only launch it on a machine where that is acceptable. The script then checks the mod's trace. Errors: the stages exec then final review, the 3 tasks done, a review of each task in SDD, a verification of each task inline, RED then GREEN for each done task, reported by the model or observed by the mod (in both modes), and, if the run reached finishing, its menu last. Warnings, which describe the flow the model chose and do not fail the run: task <n>: red/green not reported by the model (the model did not report them itself), finishing never reached, and, in SDD, no dispatch seen at agent.spawn (only the tool.call fallback). It prints E2E <mode>: OK (with any warnings) or the list of problems, and copies each trace, with the matching stream, to fixtures-local/e2e/<version>-<fixture>-<mode>-<stamp>.{trace,stream}.jsonl. The run costs tokens (model sonnet) and takes several minutes per mode; it needs claude and jq, plus bats, since the default runs the bats fixture too.
  npm run e2e -- ~/.claude/plugins/cache/superpowers-marketplace/superpowers/7.0.0 all

With no third argument this runs both modes on both fixtures, so four runs.

/sp-progress

/sp-progress opens the pane, or closes it if it is open. Where the surface cannot show the pane, the command answers "Progress pane cannot be shown here." and the pane is not counted as open.

The pane also opens by itself when the plan's execution starts, but only where it sits beside the transcript (fullscreen terminal). Elsewhere, it waits for the command.

notifications option

The notifications option (on by default) turns the mod's notifications off when disabled. It is set at installation.

Notifications go through your usual channel, the one Claude Code's preferredNotifChannel setting names (/config, Local notifications).

Turning off Claude Code's generic notification

Claude Code sends its own notification when a task ends or a permission is waiting. During a run, it can double the mod's. According to Claude Code's documentation at the time of writing:

  • preferredNotifChannel set to "notifications_disabled" turns off Claude Code's notifications, but also the mod's, which go through the same channel. If you only want to keep one of the two, keep Claude Code's and disable the mod's notifications option.
  • The Notification hooks in your settings run in addition to the built-in notification, without replacing it: they cannot suppress it. They also receive the mod's notifications (plugin_notification field), which lets you, if you notify through your own hooks, handle the mod's and Claude Code's differently (the matcher works on the notification type, for example idle_prompt or permission_prompt).
Source 22 files
hooks/register.tsx 220 lines
1// Two rules of the engine's scan of this module, which refuses to load it otherwise; band, pane and rebuild follow them:
2// - `$` is followed only into functions declared in this file, never across an import: helpers in other files get
3//   what `portOf($)` builds (EnginePort), or are hooks passed to `on` by reference.
4// - `read`, `update` and `$.state.*` take only an atom or reference written in the file that uses it, never an
5//   imported one: each such file declares its own `atom({ plugin: 'superpowers-statusbar', key: 'run' } as const, null)`.
6//   Likewise `$.env.get` takes a literal variable name.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, PluginOptions, Register } from 'claude-code'
10
11import type { ObservedEvent, RunState } from '../types'
12
13import { REPORT_TOOL } from '../src/instrument.ts'
14import { messagesToEvents, stopAgents } from '../src/rebuild.ts'
15import { isDockingSurface, renderBand } from './band.tsx'
16import { callEvents, spawnEvent, toolEvent } from './normalize.ts'
17import { PANE_ID, PANE_TITLE, renderPane } from './pane.tsx'
18import { observe, resetTrace } from './runtime.ts'
19import { onSpinnerFault } from './spin.ts'
20import type { EnginePort } from './runtime.ts'
21
22/**
23 * The run the status bar shows; `null` until a superpowers skill starts one. Declared here: the engine reads
24 * state only through an atom or reference written in the file that hooks it, never one imported.
25 */
26const runAtom = atom({ plugin: 'superpowers-statusbar', key: 'run' } as const, null)
27
28/** Whether the progress pane is open, as `/sp-progress`, the opening at exec and the person's close leave it. */
29const paneOpenAtom = atom({ plugin: 'superpowers-statusbar', key: 'isPaneOpen' } as const, false)
30
31const REPORT = 'mcp__superpowers-statusbar__report_phase'
32const REPORTED = { result: 'ok' } as const
33
34/** What `observe` reaches of the engine; `$` itself cannot cross into another file. */
35function portOf($: EngineInterface): EnginePort {
36  return {
37    read: () => read($, runAtom),
38    update: change => update($, runAtom, change),
39    notify: (text, options) => $.ui.notify(text, options),
40    tracePath: () => $.env.get('SP_STATUSBAR_TRACE'),
41    readFile: path => $.fs.read(path),
42    writeFile: (path, text) => $.fs.write(path, text),
43    entered: stage => {
44      if (stage === 'exec' && isDockingSurface()) void openPane($).catch(() => {})
45    },
46  }
47}
48
49/**
50 * Opens the progress pane and tells whether the engine placed it. A pane it left undrawn (a terminal too narrow:
51 * 144 columns unasked, 110 once asked) is closed again and not counted open.
52 */
53async function openPane($: EngineInterface): Promise<boolean> {
54  const opened = await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
55  if (!opened.isPlaced) {
56    await $.ui.close({ id: PANE_ID })
57    await update($, paneOpenAtom, () => false)
58    return false
59  }
60  await update($, paneOpenAtom, () => true)
61  return true
62}
63
64const TICK_MS = 60_000
65/** A timer silent for longer than this is taken as ended (the engine ends an interval whose period it refused). */
66const TICK_STALE_MS = 3 * TICK_MS
67
68/** The timer this copy of the module runs (a hot reload cancels it with the module it ran in), and when it last ticked or began. */
69let timer: { cancel: () => void } | undefined
70let lastTick = 0
71
72/**
73 * Keeps, once per copy of the module, a timer that redraws the band and the pane every minute while a run is
74 * underway, so their elapsed time and estimate move on when no observation writes the run. A timer that has not
75 * ticked for more than 3 minutes has ended: it is cancelled and started again.
76 */
77function tickEveryMinute($: EngineInterface, now: number) {
78  if (timer) {
79    if (now - lastTick <= TICK_STALE_MS) return
80    try { timer.cancel() } catch {}
81    timer = undefined
82  }
83  lastTick = now
84  try {
85    timer = $.clock.every(TICK_MS, () => {
86      void $.clock.now().then(at => { lastTick = at }, () => {})
87      void read($, runAtom).then(run => {
88        if (run && run.stage !== 'done') $.ui.invalidate('ui.render')
89      }, () => {})
90    })
91  } catch {
92    timer = undefined
93  }
94}
95
96/** Observes, in order, the events `events` builds for the current time; neither the clock nor observing fails the hook. */
97async function seen($: EngineInterface, events: (at: number) => ObservedEvent | ObservedEvent[], options: PluginOptions) {
98  let at: number
99  try {
100    at = await $.clock.now()
101  } catch {
102    return
103  }
104  tickEveryMinute($, at)
105  for (const e of [events(at)].flat()) await observe(portOf($), e, options)
106}
107
108/**
109 * Rebuilds the run from the session transcript when the session starts with a transcript and no run (a resume).
110 * A run already stored (reload, compaction) is kept, and an empty transcript adopts nothing. The events go
111 * through `observe` as live ones do, files served by `$.fs.read`, but on a local run, apart from the stored one and
112 * silent: no notification, trace or pane. Only when the whole replay succeeded is the run stored, once, and only
113 * if none was stored meanwhile; then no agent is left running (`stopAgents`), so the next main turn end can wait.
114 * Failing to read or replay the transcript leaves the run as it is.
115 */
116async function rebuildFromTranscript($: EngineInterface, options: PluginOptions) {
117  try {
118    if ((await read($, runAtom)) !== null) return
119    const messages = await $.session.messages()
120    if (!Array.isArray(messages) || messages.length === 0) return
121    const events = messagesToEvents(messages)
122    let rebuilt: RunState | null = null
123    const port: EnginePort = {
124      read: async () => rebuilt,
125      update: async change => { rebuilt = change(rebuilt) },
126      notify: async () => {},
127      tracePath: async () => undefined,
128      readFile: path => $.fs.read(path),
129      writeFile: async () => {},
130      entered: () => {},
131    }
132    for (const e of events) await observe(port, e, { ...options, notifications: false })
133    const run = rebuilt as RunState | null
134    if (run) await update($, runAtom, s => s ?? stopAgents(run))
135  } catch {}
136}
137
138export const register: Register = (on, options) => {
139  on('session.start', async ($, e, next) => {
140    await Promise.all([
141      $.tool.register(REPORT_TOOL),
142      $.command.register({ name: 'sp-progress', description: 'Open or close the superpowers progress pane' }),
143    ])
144    await rebuildFromTranscript($, options)
145    try { tickEveryMinute($, await $.clock.now()) } catch {}
146    return next(e)
147  }).catch(($, e, next) => next(e))
148
149  on('command.run', { command: 'sp-progress' }, async $ => {
150    if (await read($, paneOpenAtom)) {
151      await $.ui.close({ id: PANE_ID })
152      await update($, paneOpenAtom, () => false)
153      return { text: 'Progress pane closed.' }
154    }
155    return { text: await openPane($) ? 'Progress pane opened.' : 'Progress pane cannot be shown here.' }
156  }).catch(() => ({ text: 'Progress pane unavailable.' }))
157
158  // A skill typed as `/superpowers:…` reaches `command.run`; the security plugin bypasses `skill.prompt`, which has no agentId.
159  on('command.run', async ($, e, next) => {
160    if (e.command !== 'sp-progress') await seen($, at => ({ kind: 'command', command: e.command, at }), options)
161    return next(e)
162  }).catch(($, e, next) => next(e))
163
164  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
165    const closed = await next(e)
166    await update($, paneOpenAtom, () => false)
167    return closed
168  }).catch(($, e, next) => next(e))
169
170  on('ui.render', { component: 'AbovePrompt' }, renderBand).catch(($, e, next) => next(e))
171
172  on('ui.render', { component: 'Pane', requestId: PANE_ID }, renderPane)
173
174  // A spinner `Client` that failed is left out from then on: the engine draws its site again, with a still mark.
175  on('ui.fault', ($, e, next) => {
176    onSpinnerFault(e)
177    return next(e)
178  }).catch(($, e, next) => next(e))
179
180  on('tool.call', { tool: REPORT }, async ($, e) => {
181    await seen($, at => toolEvent(e, REPORTED, at), options)
182    return REPORTED
183  }).catch(() => REPORTED)
184
185  on('tool.call', async ($, e, next) => {
186    if (e.tool === REPORT) return next(e)
187    const ran = await next(e)
188    await seen($, at => callEvents(e, ran, at), options)
189    return ran
190  }).catch(($, e, next) => next(e))
191
192  on('agent.spawn', async ($, e, next) => {
193    const r = await next(e)
194    // Only the model's own Agent tool starts a superpowers dispatch: another plugin's `$.agent.spawn` is not one.
195    if (next.origin.plugin !== 'engine') return r
196    await seen($, at => spawnEvent(e, r, at) ?? [], options)
197    return r
198  }).catch(($, e, next) => next(e))
199
200  on('turn.start', async ($, e, next) => {
201    const started = await next(e)
202    await seen($, at => ({ kind: 'turn-start', at }), options)
203    return started
204  }).catch(($, e, next) => next(e))
205
206  on('turn.complete', async ($, e, next) => {
207    const completed = await next(e)
208    await seen($, at => ({ kind: 'turn-end', ...(e.agentId ? { agentId: e.agentId } : {}), answer: e.answer, at }), options)
209    return completed
210  }).catch(($, e, next) => next(e))
211
212  on('session.end', async ($, e, next) => {
213    if (e.reason === 'clear') {
214      resetTrace()
215      await update($, runAtom, () => null)
216    }
217    return next(e)
218  }).catch(($, e, next) => next(e))
219}
220
src/instrument.ts 69 lines
1import type { ReportedPhase } from '../types'
2
3type ReportingSkill = 'brainstorming' | 'writing-plans' | 'test-driven-development'
4
5/** The phases each skill reports, in the order the skill goes through them. */
6const PHASES_BY_SKILL: Record<ReportingSkill, readonly ReportedPhase[]> = {
7  'brainstorming': ['conversation', 'design-writing', 'builder-check', 'awaiting-approval'],
8  'writing-plans': ['drafting', 'self-review'],
9  'test-driven-development': ['red', 'green', 'refactor'],
10}
11
12/** When the model reports each phase, as the tool description words it. */
13const WHEN: Record<ReportedPhase, string> = {
14  'conversation': 'you are asking questions or playing understanding back',
15  'design-writing': 'you are writing the design document',
16  'builder-check': 'the builder check subagent is reading the document',
17  'awaiting-approval': 'the document is handed over for approval',
18  'drafting': 'you are writing the plan',
19  'self-review': 'you are running the self-review checklist',
20  'red': 'you start writing a failing test',
21  'green': 'you start writing the code that makes it pass',
22  'refactor': 'you are cleaning up with the tests green',
23}
24
25/** Every phase the tool accepts; the detector accepts the same set. */
26export const ALL_PHASES: readonly ReportedPhase[] = Object.values(PHASES_BY_SKILL).flat()
27
28const phaseLines = (skill: ReportingSkill) => PHASES_BY_SKILL[skill].map(p => `- \`${p}\`: ${WHEN[p]}`).join('\n')
29
30/**
31 * The reporting instructions, which the model reads in the tool list on every request and in every subagent:
32 * Claude Code's built-in security plugin skips plugins' `skill.prompt` hooks, so the skills' text cannot carry them.
33 */
34const DESCRIPTION = [
35  'Reports the current superpowers phase to the status bar your human partner watches. Call it only while following '
36    + 'superpowers\' brainstorming, writing-plans or test-driven-development skill: once each time you move into one of '
37    + 'the phases below, then carry on. It is a signal only: it changes nothing about how you follow the skill, and you '
38    + 'report nothing else. This includes executing a plan yourself (superpowers\' executing-plans): report red, green '
39    + 'and refactor for each task as you go, as an implementer subagent does.',
40  ...(Object.keys(PHASES_BY_SKILL) as ReportingSkill[]).map(skill => `${skill}:\n${phaseLines(skill)}`),
41].join('\n\n')
42
43/** The tool the model calls to report a phase, listed to it as `mcp__superpowers-statusbar__report_phase`. */
44export const REPORT_TOOL: { name: 'report_phase'; description: string; inputSchema: Record<string, unknown>; isDeferred: false } = {
45  name: 'report_phase',
46  description: DESCRIPTION,
47  inputSchema: {
48    type: 'object',
49    properties: { phase: { type: 'string', enum: ALL_PHASES } },
50    required: ['phase'],
51  },
52  isDeferred: false,
53}
54
55/**
56 * The same instruction, put in context at session and subagent start by the `SessionStart` and `SubagentStart`
57 * command hooks of `hooks/hooks.json`: Sonnet follows it far better there than from the tool description alone.
58 */
59export const STARTUP_CONTEXT =
60  'Status bar reporting (superpowers-statusbar plugin): whenever you follow superpowers test-driven-development, '
61  + 'including when you execute a plan yourself with superpowers:executing-plans and when you implement a task as a '
62  + 'subagent, call the mcp__superpowers-statusbar__report_phase tool each time you move into a TDD phase: red when '
63  + 'you start writing a failing test, green when you start writing the code that makes it pass, refactor when you '
64  + 'clean up with the tests green. It is a signal only; it changes nothing about how you work.'
65
66/** What a `SessionStart` or `SubagentStart` command hook prints on stdout. */
67export const startupHookOutput = (event: 'SessionStart' | 'SubagentStart'): string =>
68  JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: STARTUP_CONTEXT } })
69
src/rebuild.ts 53 lines
1import type { ObservedEvent, RunState } from '../types'
2import { skillOfCall } from './skill-call.ts'
3
4/** The part of a `SessionMessage` the rebuild reads; structural, so `src/` imports nothing from `'claude-code'`. */
5export type TranscriptMessage = {
6  role: 'user' | 'assistant'
7  text: string
8  toolUses: readonly {
9    tool: string
10    input: Record<string, unknown>
11    result?: unknown
12    text?: string
13    isError?: true
14    agentId?: string
15  }[]
16}
17
18/**
19 * The events a session transcript shows the detectors, in order, all at time 0 (unknown). A `Skill` call that
20 * did not fail also counts as that skill starting, placed before the call itself: the rule `hooks/normalize.ts`
21 * applies live. The transcript keeps no turn boundaries, so no `turn-end` is emitted.
22 */
23export function messagesToEvents(messages: readonly TranscriptMessage[]): ObservedEvent[] {
24  const events: ObservedEvent[] = []
25  for (const m of messages) {
26    for (const u of m.toolUses) {
27      const skill = skillOfCall(u, 0)
28      if (skill) events.push(skill)
29      events.push({
30        kind: 'tool',
31        tool: u.tool,
32        input: u.input,
33        ...(u.result !== undefined ? { result: u.result } : {}),
34        ...(u.text !== undefined ? { text: u.text } : {}),
35        ...(u.isError ? { isError: true } : {}),
36        ...(u.agentId ? { agentId: u.agentId } : {}),
37        at: 0,
38      })
39    }
40  }
41  return events
42}
43
44/**
45 * The run as a resume leaves it: no agent still running. Background agents finish through task notifications the
46 * transcript does not replay as turn ends, and none survives the session that launched it.
47 */
48export function stopAgents(s: RunState): RunState {
49  const running = Object.entries(s.agents).filter(([, r]) => r.isRunning)
50  if (running.length === 0) return s
51  return { ...s, agents: { ...s.agents, ...Object.fromEntries(running.map(([id, r]) => [id, { ...r, isRunning: false }])) } }
52}
53
hooks/band.tsx 52 lines
1// Follows the engine's two rules spelled out at the top of register.tsx: this hook is passed to `on` by reference,
2// and reads the run through an atom declared here.
3
4import { atom, read } from 'claude-code'
5import type { MatchedHook, TextProps } from 'claude-code'
6
7import { bandSegments } from '../src/view.ts'
8import type { Tone } from '../src/view.ts'
9import { canSpin } from './spin.ts'
10
11const runAtom = atom({ plugin: 'superpowers-statusbar', key: 'run' } as const, null)
12
13/** The theme's colours for each tone, so the band follows the person's theme. */
14export const TONES: Record<Tone, TextProps> = {
15  normal: {},
16  subtle: { color: 'subtle' },
17  ok: { color: 'success' },
18  active: { color: 'warning' },
19  wait: { backgroundColor: 'warning', color: 'inverseText' },
20}
21
22/** What the band last read of `e.viewport.isFullscreen`: whether this session's surface docks a pane. */
23let isDockingSeen: boolean | undefined
24
25/** True when the last band drawn was on a surface that docks a pane beside the transcript. */
26export const isDockingSurface = () => isDockingSeen === true
27
28/**
29 * Draws the run as one line above the prompt on the terminal and the desktop, sized to `e.props.bodyColumns`, its
30 * running mark a spinner (`./spinner.tsx`) where the surface animates one.
31 * Leaves the band to the engine without a run, while a survey holds it, and on the surfaces it does not draw on.
32 */
33export const renderBand: MatchedHook<'ui.render', { component: 'AbovePrompt' }> = async ($, e, next) => {
34  if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
35  if (e.viewport?.isFullscreen !== undefined) isDockingSeen = e.viewport.isFullscreen
36  if (e.props.hasSurvey) return next(e)
37  const run = await read($, runAtom)
38  if (run === null) return next(e)
39  const columns = e.props.bodyColumns
40  const segments = bandSegments(run, await $.clock.now(), columns)
41  const table = $.ui.resolve(e)
42  const { Box, Text } = table
43  const Client = 'Client' in table && canSpin(e.surface) ? table.Client : undefined
44  return (
45    <Box flexDirection="row" width={columns} height={1}>
46      {segments.map(g => g.isSpinner && Client
47        ? <Client key="spinner" module="./spinner.tsx" props={null} width={1} />
48        : <Text {...TONES[g.tone]} {...(g.isBold ? { bold: true } : {})}>{g.text}</Text>)}
49    </Box>
50  )
51}
52
hooks/normalize.ts 40 lines
1import type { AgentSpawnInput, AgentSpawnResult, ToolCallInput, ToolCallResult } from 'claude-code'
2
3import type { ObservedEvent } from '../types'
4import { skillOfCall } from '../src/skill-call.ts'
5
6/** Keys `tool.call` carries beside the tool's own arguments. */
7const ENVELOPE: ReadonlySet<string> = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta', 'consent'])
8
9/** A finished tool call as the detectors read it: the tool's arguments as `input`, then how it ended. */
10export function toolEvent(e: ToolCallInput, ran: ToolCallResult, at: number): Extract<ObservedEvent, { kind: 'tool' }> {
11  const input = Object.fromEntries(Object.entries(e).filter(([k]) => !ENVELOPE.has(k)))
12  const outcome = ran.deny !== undefined
13    ? { text: ran.deny, isError: true, isDenied: true as const }
14    : {
15        ...(ran.result !== undefined ? { result: ran.result } : {}),
16        ...(ran.text !== undefined ? { text: ran.text } : {}),
17        ...(ran.isError ? { isError: true } : {}),
18      }
19  return { kind: 'tool', tool: e.tool, input, ...outcome, ...(e.agentId ? { agentId: e.agentId } : {}), at }
20}
21
22/**
23 * What a finished tool call shows the detectors: a `Skill` call that ran also counts as that skill starting,
24 * observed before the call itself: Claude Code's security plugin skips plugins' `skill.prompt` hooks, which would say it.
25 */
26export function callEvents(e: ToolCallInput, ran: ToolCallResult, at: number): ObservedEvent[] {
27  const call = toolEvent(e, ran, at)
28  const skill = skillOfCall(call, at)
29  return skill ? [skill, call] : [call]
30}
31
32/**
33 * A subagent the engine started, as the detectors read it. Nothing for a spawn that gave no `agentId` (refused or
34 * answered without starting one), nor for a workflow script's agent or a teammate: neither is a superpowers dispatch.
35 */
36export function spawnEvent(e: AgentSpawnInput, r: AgentSpawnResult, at: number): Extract<ObservedEvent, { kind: 'spawn' }> | undefined {
37  if (!r.agentId || e.workflow || e.isTeammate) return undefined
38  return { kind: 'spawn', agentId: r.agentId, description: e.description, prompt: e.prompt, ...(e.parentAgentId ? { parentAgentId: e.parentAgentId } : {}), at }
39}
40
hooks/pane.tsx 104 lines
1// Follows the engine's two rules spelled out at the top of register.tsx: this hook is passed to `on` by reference,
2// and reads the run through an atom declared here.
3
4import { atom, read } from 'claude-code'
5import type { MatchedHook, RenderChildren, TextProps } from 'claude-code'
6
7import type { Decision } from '../types'
8import { paneModel } from '../src/view.ts'
9import type { Mark } from '../src/view.ts'
10import { TONES } from './band.tsx'
11import { canSpin } from './spin.ts'
12
13/** The pane's id, for `$.ui.open`, `$.ui.close` and the `ui.render` matcher. */
14export const PANE_ID = 'sp-progress'
15export const PANE_TITLE = 'superpowers'
16
17const runAtom = atom({ plugin: 'superpowers-statusbar', key: 'run' } as const, null)
18
19const MARKS: Record<Mark, string> = { done: '✓', active: '●', todo: '○', skipped: '–' }
20/** A line's colour by where it stands: done, under way, not started (default text), skipped. */
21const STATUS_TONES: Record<Mark, TextProps> = { done: TONES.ok, active: TONES.active, todo: TONES.normal, skipped: TONES.subtle }
22const DECISION_LABELS: Record<Decision['kind'], string> = { ruling: 'Ruling', minor: 'minor', parked: 'parked' }
23/** Where a milestone sits under its stage's label: past the mark and its space. */
24const MILESTONE_INDENT = '  '
25
26const decisionTag = (d: Decision) => `${DECISION_LABELS[d.kind]}${d.task ? ` · Task ${d.task}` : ''} · `
27
28/**
29 * Draws the run in the pane, in five blocks (stages, tasks, current task, decisions, estimate), each left out
30 * when it has nothing to show; the task list fits the surface's height. One line per stage, task and phase of the
31 * current task: its mark and label in the colour of where it stands, details in `subtle`, a finished one's duration
32 * on the right edge; what is under way turns a spinner (`./spinner.tsx`) where the surface animates one, unless the
33 * run waits on the person.
34 */
35export const renderPane: MatchedHook<'ui.render', { component: 'Pane', requestId: 'sp-progress' }> = async ($, e) => {
36  const table = $.ui.resolve(e)
37  const { Box, Text } = table
38  const Client = 'Client' in table && canSpin(e.surface) ? table.Client : undefined
39  const run = await read($, runAtom)
40  if (run === null) return <Box><Text {...TONES.subtle}>No superpowers run yet.</Text></Box>
41  const columns = e.props.bodyColumns
42  const m = paneModel(run, await $.clock.now(), e.viewport?.rows ?? 24)
43
44  // While the run waits on the person nothing turns: what is under way keeps a still mark.
45  const mark = (status: Mark, spinnerKey: string) => status === 'active' && Client && !run.waiting
46    ? <Client key={spinnerKey} module="./spinner.tsx" props={null} width={1} />
47    : <Text {...STATUS_TONES[status]}>{MARKS[status]}</Text>
48  /** One line: mark, label, `details` in subtle after it, `right` (a duration) against the right edge. */
49  const line = (status: Mark, spinnerKey: string, label: string, details: string[], right?: string) => (
50    <Box flexDirection="row" width={columns} justifyContent="space-between">
51      <Box flexDirection="row" flexShrink={1}>
52        {mark(status, spinnerKey)}
53        <Text {...STATUS_TONES[status]} wrap="truncate-end">{` ${label}`}</Text>
54        {details.map(d => <Text {...TONES.subtle} wrap="truncate-end">{` · ${d}`}</Text>)}
55      </Box>
56      {right !== undefined && <Box flexShrink={0}><Text {...TONES.subtle}>{` ${right}`}</Text></Box>}
57    </Box>
58  )
59  const block = (title: string, lines: RenderChildren[]) => (
60    <Box flexDirection="column" marginTop={1}>
61      <Text bold>{title}</Text>
62      {lines}
63    </Box>
64  )
65
66  const stages = m.stages.flatMap(g => [
67    line(g.status, 'stage-spinner', g.label, g.detail ? [g.detail] : [], g.duration),
68    ...g.milestones.map(label => <Text {...TONES.subtle}>{`${MILESTONE_INDENT}${label}`}</Text>),
69  ])
70  const tasks = m.tasks.map(t =>
71    line(t.status, `task-spinner-${t.n}`, `${t.n}. ${t.title}`, t.fixRounds > 0 ? [`fix ${t.fixRounds}`] : [], t.duration))
72  if (m.hiddenTasks > 0) tasks.push(<Text {...TONES.subtle}>{`(+${m.hiddenTasks})`}</Text>)
73  const current = m.current && [
74    <Text>{`Task ${m.current.n} · ${m.current.title}`}</Text>,
75    ...m.current.trail.map(p => line(p.status, 'phase-spinner', p.label, p.verdict ? [p.verdict] : [])),
76  ]
77  const decisions = m.decisions.map(d => (
78    <Box flexDirection="row" width={columns}>
79      <Box flexShrink={0}><Text {...TONES.subtle}>{decisionTag(d)}</Text></Box>
80      <Text>{d.text}</Text>
81    </Box>
82  ))
83  // The latest decisions are shown: the earlier ones left out are counted above them.
84  if (m.hiddenDecisions > 0) decisions.unshift(<Text {...TONES.subtle}>{`(+${m.hiddenDecisions})`}</Text>)
85  const eta = m.eta && (
86    <Box flexDirection="row">
87      {m.eta.split(' · ').flatMap((part, i) => [...(i > 0 ? [<Text {...TONES.subtle}> · </Text>] : []), <Text>{part}</Text>])}
88    </Box>
89  )
90  return (
91    <Box flexDirection="column" width={columns}>
92      <Box flexDirection="row">
93        <Text bold>{m.title}</Text>
94        {m.elapsed && <Text {...TONES.subtle}>{` · ${m.elapsed}`}</Text>}
95      </Box>
96      {block('STAGES', stages)}
97      {tasks.length > 0 && block('TASKS', tasks)}
98      {current && block('CURRENT TASK', current)}
99      {decisions.length > 0 && block('DECISIONS & SET ASIDE', decisions)}
100      {eta && block('ESTIMATE', [eta])}
101    </Box>
102  )
103}
104
hooks/runtime.ts 130 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { FileRequest, ObservedEvent, RunState, Stage, TraceEntry } from '../types'
4import { detect } from '../src/detectors/index.ts'
5import { reduce } from '../src/reduce.ts'
6import { notificationFor } from '../src/notify.ts'
7import { traceEntry } from '../src/trace.ts'
8
9/**
10 * What observing needs from the engine. The engine only lets `$` be passed to functions of the file that
11 * hooks it, so `register.tsx` builds this from `$` (`portOf`) and hands it here.
12 */
13export type EnginePort = {
14  /** The stored run (`read($, runAtom)`). */
15  read: () => Promise<RunState | null>
16  /** `update($, runAtom, change)`: `change` may run again on a version miss. */
17  update: (change: (s: RunState | null) => RunState | null) => Promise<unknown>
18  notify: (text: string, options: { title: string }) => Promise<unknown>
19  /** The trace file the environment names (`SP_STATUSBAR_TRACE`), if any. */
20  tracePath: () => Promise<string | undefined>
21  /** Rejects when the file is missing. */
22  readFile: (path: string) => Promise<string>
23  writeFile: (path: string, text: string) => Promise<void>
24  /** Told, once the step is written, that the run entered `stage`; never awaited. */
25  entered: (stage: Stage) => void
26}
27
28/** What one event did to the run: the state before and after, a trace entry per action, the files to read. */
29type Step = { prev: RunState | null; next: RunState | null; entries: TraceEntry[]; reads: FileRequest[] }
30
31function step(prev: RunState | null, e: ObservedEvent): Step {
32  const d = detect(e, prev)
33  let next = prev
34  const entries: TraceEntry[] = []
35  for (const a of d.actions) {
36    const before = next
37    next = reduce(next, a)
38    entries.push({ ...traceEntry(a, next, before), via: e.kind })
39  }
40  return { prev, next, entries, reads: d.reads }
41}
42
43/**
44 * Applies `e` to the run against its latest state. An event that changes nothing is settled on the value read;
45 * one that does goes through `update`, whose function may run again on a version miss: the step kept is the one
46 * computed from the value actually replaced.
47 */
48async function apply(port: EnginePort, e: ObservedEvent): Promise<Step> {
49  const seen = step(await port.read(), e)
50  if (seen.entries.length === 0) return seen
51  const written: { step?: Step } = {}
52  await port.update(s => (written.step = step(s, e)).next)
53  return written.step ?? seen
54}
55
56/** Trace lines of this copy of the module since the run began (the file is rewritten whole each time). */
57const traceLines: string[] = []
58let inFlight: Promise<void> | undefined
59let queued: Promise<void> | undefined
60
61/** Forgets the lines so far: a new run (after `/clear`) starts a new trace. */
62export function resetTrace(): void {
63  traceLines.length = 0
64}
65
66/**
67 * Writes every line so far. Writes land in order and coalesce: while one is in flight, the lines that come meanwhile
68 * wait for one more write, which carries them all.
69 */
70function writeTrace(port: EnginePort, path: string): Promise<void> {
71  if (queued) return queued
72  if (inFlight) {
73    queued = inFlight.catch(() => {}).then(() => {
74      queued = undefined
75      return writeTrace(port, path)
76    })
77    return queued
78  }
79  const write = (async () => port.writeFile(path, `${traceLines.join('\n')}\n`))()
80  inFlight = write.finally(() => { inFlight = undefined })
81  return inFlight
82}
83
84async function trace(port: EnginePort, entries: readonly TraceEntry[]) {
85  if (entries.length === 0) return
86  const path = await port.tracePath()
87  if (!path) return
88  traceLines.push(...entries.map(entry => JSON.stringify(entry)))
89  await writeTrace(port, path)
90}
91
92/** Swallows a side effect's failure: observing never fails the hook that observes. */
93const quietly = (work: Promise<unknown>) => work.then(() => {}, () => {})
94
95/**
96 * Feeds one observed event to the run: detection and reduction on the stored state, `port.entered` when the run
97 * changed stage, a notification when the run enters a wait worth one (and `options.notifications` is on), a trace
98 * line per action when `SP_STATUSBAR_TRACE` names a file, then each file the detectors asked for, read through the
99 * engine and fed back as a `file` event (a missing file is skipped). Never rejects.
100 */
101export async function observe(port: EnginePort, e: ObservedEvent, options: PluginOptions): Promise<void> {
102  let done: Step
103  try {
104    done = await apply(port, e)
105  } catch {
106    return
107  }
108  if (done.next && done.next.stage !== done.prev?.stage) {
109    try {
110      port.entered(done.next.stage)
111    } catch {}
112  }
113  // Started first, awaited last: a notification slow or throwing must not hold back the trace and the reads.
114  const text = notificationFor(done.prev, done.next)
115  const notified = text !== undefined && options.notifications === true
116    ? quietly((async () => port.notify(text, { title: 'superpowers' }))())
117    : undefined
118  await quietly(trace(port, done.entries))
119  for (const r of done.reads) {
120    let content: string
121    try {
122      content = await port.readFile(r.path)
123    } catch {
124      continue
125    }
126    await observe(port, { kind: 'file', role: r.role, path: r.path, text: content, at: e.at }, options)
127  }
128  await notified
129}
130
hooks/spin.ts 25 lines
1import type { RenderSurface } from 'claude-code'
2
3/** The surfaces where a spinner `Client` failed in this copy of the module (`ui.fault`). */
4const faulted = new Set<RenderSurface>()
5
6/** Records that a spinner failed on `surface`: the band and the pane draw a still `●` there from now on. */
7export function spinnerFailed(surface: RenderSurface): void {
8  faulted.add(surface)
9}
10
11/** Forgets every fault recorded: for tests, so one test's fault does not reach the next. */
12export function resetSpinnerFaults(): void {
13  faulted.clear()
14}
15
16/** Whether `surface` animates the spinner: terminal and desktop draw a `Client`, unless one failed there. */
17export function canSpin(surface: RenderSurface): surface is 'terminal' | 'desktop' {
18  return (surface === 'terminal' || surface === 'desktop') && !faulted.has(surface)
19}
20
21/** What `ui.fault` tells of a failed `Client`: only the spinner's own module fails the spinner. */
22export function onSpinnerFault(e: { surface: RenderSurface; module: string }): void {
23  if (e.module.endsWith('spinner.tsx')) spinnerFailed(e.surface)
24}
25
src/skill-call.ts 15 lines
1import type { ObservedEvent } from '../types'
2
3/**
4 * The skill starting that a finished `Skill` call shows, or nothing: a call that failed, or that names no skill,
5 * starts none. The call's `agentId` (a subagent made it) travels with the event.
6 */
7export function skillOfCall(
8  call: { tool: string; input: Record<string, unknown>; isError?: boolean; agentId?: string },
9  at: number,
10): Extract<ObservedEvent, { kind: 'skill' }> | undefined {
11  const skill = call.tool === 'Skill' && !call.isError ? call.input.skill : undefined
12  if (typeof skill !== 'string') return undefined
13  return { kind: 'skill', skill, ...(call.agentId ? { agentId: call.agentId } : {}), at }
14}
15
src/view.ts 371 lines
1import type { ReportedPhase, RunState, Stage, TaskEvent, TaskPhase, TaskState, Waiting } from '../types'
2import { currentTask } from './selectors.ts'
3import { waitText } from './notify.ts'
4
5/**
6 * What a piece of text means, which the render hooks turn into the theme's colours: `ok` done, `active` under way,
7 * `subtle` secondary (durations, separators, verdicts, tags), `wait` the highlighted waiting block.
8 */
9export type Tone = 'normal' | 'subtle' | 'ok' | 'active' | 'wait'
10/** `isSpinner`: the mark of something running, which a surface that can animates in place of its text. */
11export type Segment = { text: string; tone: Tone; isBold?: boolean; isSpinner?: boolean }
12
13/** Where a stage, task or task phase stands. */
14export type Mark = 'done' | 'active' | 'todo' | 'skipped'
15
16export type PaneModel = {
17  title: string
18  elapsed?: string
19  /** `duration` once the stage is finished and its span known; `milestones` under brainstorm and plan only. */
20  stages: { label: string; status: Mark; detail?: string; duration?: string; milestones: string[] }[]
21  tasks: { n: string; title: string; status: TaskState['status']; duration?: string; fixRounds: number }[]
22  hiddenTasks: number
23  /** The current task's phases passed (`done`), under way (`active`) and still expected (`todo`), in order. */
24  current?: { n: string; title: string; trail: { label: string; status: Exclude<Mark, 'skipped'>; verdict?: string }[] }
25  /** The latest decisions, as many as the rows left allow (at least MIN_DECISION_ROWS); `hiddenDecisions` earlier ones. */
26  decisions: RunState['decisions']
27  hiddenDecisions: number
28  eta?: string
29}
30
31const BAR_CELLS = 15
32const MAX_FIX_ROUNDS = 5
33const STAGES: readonly Stage[] = ['brainstorm', 'plan', 'exec', 'final-review', 'finishing', 'done']
34/** Rows the pane spends beside the task list, before milestones and the current task's trail. */
35const PANE_CHROME_ROWS = 14
36const MIN_TASK_ROWS = 3
37/** Rows the decisions block spends beside its lines: the gap above it and its title. */
38const DECISION_CHROME_ROWS = 2
39const MIN_DECISION_ROWS = 3
40
41const REPORTED_LABELS: Record<ReportedPhase, string> = {
42  conversation: 'conversation',
43  'design-writing': 'writing the spec',
44  'builder-check': 'builder check',
45  'awaiting-approval': 'spec awaiting approval',
46  drafting: 'drafting',
47  'self-review': 'plan self-review',
48  red: 'RED',
49  green: 'GREEN',
50  refactor: 'refactor',
51}
52
53// --- formatting ---
54
55export function formatDuration(ms: number): string {
56  const minutes = Math.max(0, Math.floor(ms / 60000))
57  if (minutes < 60) return `${minutes}m`
58  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
59}
60
61export function formatClock(at: number): string {
62  const d = new Date(at)
63  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
64}
65
66export function phaseLabel(e: TaskEvent): string {
67  switch (e.phase) {
68    case 'brief': return 'brief'
69    case 'writing': return 'implementing'
70    case 'review': return 'review'
71    case 'fix': return `fix ${e.round ?? '?'}/${MAX_FIX_ROUNDS}`
72    case 're-review': return `re-review ${e.round ?? '?'}`
73    case 'red': return 'RED'
74    case 'green': return 'GREEN'
75    case 'refactor': return 'refactor'
76    case 'verify': return 'verify'
77    case 'done': return 'done'
78    case 'unknown': return '?'
79  }
80}
81
82export function stageLabel(s: RunState): string {
83  switch (s.stage) {
84    case 'brainstorm': return 'brainstorming'
85    case 'plan': return 'plan'
86    case 'exec': return s.mode === 'sdd' ? 'exec SDD' : s.mode === 'direct' ? 'exec inline' : 'exec'
87    case 'final-review': return s.finalReview?.phase === 'fixes' ? 'final review · fixes' : 'final review'
88    case 'finishing': return 'finishing'
89    case 'done': return 'done'
90  }
91}
92
93const planSlug = (planPath: string) =>
94  (planPath.split('/').pop() ?? '').replace(/\.md$/, '').replace(/^\d{4}-\d{2}-\d{2}-/, '')
95
96// --- estimate ---
97
98export function estimate(s: RunState, now: number): { avgMs: number; remainingMs: number; finishAt: number } | undefined {
99  const spans = s.tasks.flatMap(t =>
100    t.status === 'done' && t.startedAt && t.doneAt ? [t.doneAt - t.startedAt] : [])
101  const remaining = s.tasks.filter(t => t.status !== 'done').length
102  if (!s.isPlanLoaded || spans.length === 0 || remaining === 0) return undefined
103  const avgMs = spans.reduce((a, b) => a + b, 0) / spans.length
104  const remainingMs = avgMs * remaining
105  return { avgMs, remainingMs, finishAt: now + remainingMs }
106}
107
108// --- band ---
109
110const sep = (text: string): Segment => ({ text, tone: 'subtle' })
111/** The band's lead while the run works. */
112const RUNNING: Segment = { text: '●', tone: 'active', isSpinner: true }
113const elapsedOf = (s: RunState, now: number) => (s.startedAt ? formatDuration(now - s.startedAt) : undefined)
114
115function doneBand(s: RunState, now: number): Segment[] {
116  const fixRounds = s.tasks.reduce((n, t) => n + t.fixRounds, 0)
117  const aside = s.decisions.filter(d => d.kind === 'parked' || d.kind === 'minor').length
118  const end = s.finishedAt ?? now
119  const parts = [`${s.tasks.length} tasks`, `${fixRounds} fix rounds`, `${aside} set aside`]
120  const elapsed = s.startedAt ? formatDuration(end - s.startedAt) : undefined
121  const tail: Segment[] = elapsed ? [sep(' · '), { text: elapsed, tone: 'subtle' }] : []
122  return [{ text: '✓ done', tone: 'ok', isBold: true }, ...parts.flatMap(p => [sep(' · '), { text: p, tone: 'normal' as const }]), ...tail]
123}
124
125type Parts = { isElapsed: boolean; isTitle: boolean; isBar: boolean }
126
127function counterOf(s: RunState): number {
128  const done = s.tasks.filter(t => t.status === 'done').length
129  return Math.min(s.tasks.length, done + (currentTask(s) ? 1 : 0))
130}
131
132/** How many tasks the run has: `?` until a plan was read, the tasks then being only those already started. */
133const totalOf = (s: RunState) => (s.isPlanLoaded ? String(s.tasks.length) : '?')
134
135function execBand(s: RunState, now: number, parts: Parts): Segment[] {
136  const total = s.tasks.length
137  const done = s.tasks.filter(t => t.status === 'done').length
138  const cur = currentTask(s)
139  const filled = total ? Math.floor((done / total) * BAR_CELLS) : 0
140  const counter = counterOf(s)
141
142  const head: Segment[] = [
143    RUNNING, sep(' '),
144    { text: stageLabel(s), tone: 'normal' }, sep(' '),
145  ]
146  const bar: Segment[] = !parts.isBar || !s.isPlanLoaded ? [] : [
147    { text: '▰'.repeat(filled), tone: 'ok' },
148    { text: '▱'.repeat(BAR_CELLS - filled), tone: 'normal' },
149    sep(' '),
150  ]
151  const tail: Segment[] = [{ text: `${counter}/${totalOf(s)}`, tone: 'normal', isBold: true }]
152  const more: Segment[][] = []
153  if (cur) {
154    const last = cur.trail[cur.trail.length - 1]
155    more.push([
156      { text: `Task ${cur.n}`, tone: 'normal' },
157      ...(parts.isTitle ? [{ text: `: ${cur.title}`, tone: 'normal' as const }] : []),
158    ])
159    more.push([{ text: last ? phaseLabel(last) : '?', tone: 'normal' }])
160    if (cur.fixRounds > 0 && last?.phase !== 'fix') {
161      more.push([{ text: `fix ${cur.fixRounds}/${MAX_FIX_ROUNDS}`, tone: 'normal' }])
162    }
163  }
164  const elapsed = parts.isElapsed ? elapsedOf(s, now) : undefined
165  if (elapsed) more.push([{ text: elapsed, tone: 'subtle' }])
166  return [...head, ...bar, ...tail, ...more.flatMap(m => [sep(' · '), ...m])]
167}
168
169/** The band's wording of a reported phase: the pane's, but the plan's self-review reads `self-review` there. */
170const BAND_LABELS: Record<ReportedPhase, string> = { ...REPORTED_LABELS, 'self-review': 'self-review' }
171
172const length = (segs: Segment[]) => segs.reduce((n, g) => n + g.text.length, 0)
173
174/** The part of the band after the stage: the plan's task count then the reported phase, brainstorm's phase. */
175function phaseOf(s: RunState): string | undefined {
176  const parts = [
177    ...(s.stage === 'plan' && s.tasks.length > 0 ? [`${s.tasks.length} tasks`] : []),
178    ...(s.stagePhase ? [BAND_LABELS[s.stagePhase]] : []),
179  ]
180  return parts.length > 0 ? parts.join(' · ') : undefined
181}
182
183/** Stage, phase, elapsed time; the elapsed time goes first, then the phase, when the line is longer than `columns`. */
184function simpleBand(s: RunState, now: number, columns: number): Segment[] {
185  const phase = phaseOf(s)
186  const elapsed = s.stage !== 'brainstorm' && s.stage !== 'plan' ? elapsedOf(s, now) : undefined
187  let band: Segment[] = []
188  for (const parts of [{ isPhase: true, isElapsed: true }, { isPhase: true, isElapsed: false }, { isPhase: false, isElapsed: false }]) {
189    band = [RUNNING, sep(' '), { text: stageLabel(s), tone: 'normal' }]
190    if (phase && parts.isPhase) band.push(sep(' · '), { text: phase, tone: 'normal' })
191    if (elapsed && parts.isElapsed) band.push(sep(' · '), { text: elapsed, tone: 'subtle' })
192    if (length(band) <= columns) break
193  }
194  return band
195}
196
197/** Drops, in order: elapsed time, task title, bar. Marker, stage and counter always stay. */
198function fitExecBand(s: RunState, now: number, columns: number): Segment[] {
199  const attempts: Parts[] = [
200    { isElapsed: true, isTitle: true, isBar: true },
201    { isElapsed: false, isTitle: true, isBar: true },
202    { isElapsed: false, isTitle: false, isBar: true },
203    { isElapsed: false, isTitle: false, isBar: false },
204  ]
205  let band: Segment[] = []
206  for (const parts of attempts) {
207    band = execBand(s, now, parts)
208    if (length(band) <= columns) break
209  }
210  return band
211}
212
213/** `segs` cut to `columns` cells. */
214function cutTo(segs: Segment[], columns: number): Segment[] {
215  let left = Math.max(0, columns)
216  return segs.flatMap(g => {
217    const text = g.text.slice(0, left)
218    left -= text.length
219    return text ? [{ ...g, text }] : []
220  })
221}
222
223/** The waiting block's text around the reason: ` WAITING · <reason> `, all of it highlighted. */
224const WAIT_BADGE = 'WAITING'
225const WAIT_CHROME = ` ${WAIT_BADGE} ·  `.length
226
227/**
228 * One highlighted block, ` WAITING · <reason> `, its spaces included, the reason cut with `…` to fit; in exec, the
229 * counter follows outside the block when the whole reason and the counter fit.
230 */
231function waitingBand(s: RunState, w: Waiting, columns: number): Segment[] {
232  const message = waitText(s, w)
233  const room = columns - WAIT_CHROME
234  if (room < 1) return cutTo([
235    { text: ' ', tone: 'wait' }, { text: WAIT_BADGE, tone: 'wait', isBold: true }, { text: ` · ${message} `, tone: 'wait' },
236  ], columns)
237  const shown = message.length <= room ? message : `${message.slice(0, room - 1)}…`
238  const out: Segment[] = [
239    { text: ' ', tone: 'wait' },
240    { text: WAIT_BADGE, tone: 'wait', isBold: true },
241    { text: ` · ${shown} `, tone: 'wait' },
242  ]
243  const counter = `${counterOf(s)}/${totalOf(s)}`
244  if (s.stage === 'exec' && shown === message && WAIT_CHROME + message.length + 3 + counter.length <= columns) {
245    out.push(sep(' · '), { text: counter, tone: 'normal', isBold: true })
246  }
247  return out
248}
249
250/** Every band is cut to `columns` as a last resort, after its own drop order, so none is ever wider. */
251export function bandSegments(s: RunState, now: number, columns: number): Segment[] {
252  return cutTo(bandOf(s, now, columns), columns)
253}
254
255function bandOf(s: RunState, now: number, columns: number): Segment[] {
256  if (s.stage === 'done') return doneBand(s, now)
257  if (s.waiting) return waitingBand(s, s.waiting, columns)
258  return s.stage === 'exec' ? fitExecBand(s, now, columns) : simpleBand(s, now, columns)
259}
260
261// --- pane ---
262
263/** The phases a task is expected to go through, by mode; fix and re-review rounds are shown as they happen. */
264const SDD_PHASES: readonly TaskPhase[] = ['brief', 'writing', 'review', 'done']
265const INLINE_PHASES: readonly TaskPhase[] = ['brief', 'red', 'green', 'refactor', 'verify', 'done']
266/** Where a phase outside the SDD sequence stands in it: TDD steps inside implementing, fix rounds inside review. */
267const SDD_PLACE: Partial<Record<TaskPhase, TaskPhase>> = { red: 'writing', green: 'writing', refactor: 'writing', fix: 'review', 're-review': 'review' }
268const MILESTONE_STAGES: ReadonlySet<Stage> = new Set<Stage>(['brainstorm', 'plan'])
269
270/** The tasks shown: `taskRows` of them (at least MIN_TASK_ROWS) around `centre`. */
271function visibleWindow(total: number, taskRows: number, centre: number): { start: number; end: number } {
272  const size = Math.min(total, Math.max(MIN_TASK_ROWS, taskRows))
273  const start = Math.max(0, Math.min(total - size, centre - Math.floor(size / 2)))
274  return { start, end: start + size }
275}
276
277/** How long `stage` lasted: from its start to the start of the next stage the run entered; unknown when either is. */
278function stageDuration(s: RunState, i: number): string | undefined {
279  const starts = s.stageStartedAt ?? {}
280  const from = starts[STAGES[i]!]
281  const to = STAGES.slice(i + 1).map(stage => starts[stage]).find(at => at !== undefined)
282  return from && to ? formatDuration(to - from) : undefined
283}
284
285function stagesOf(s: RunState): PaneModel['stages'] {
286  const cur = STAGES.indexOf(s.stage)
287  const done = s.tasks.filter(t => t.status === 'done').length
288  return STAGES.map((stage, i) => {
289    const label = stageLabel({ ...s, stage })
290    const milestones = MILESTONE_STAGES.has(stage) ? s.milestones.filter(m => m.stage === stage).map(m => m.label) : []
291    if (i > cur) return { label, status: 'todo' as const, milestones }
292    if (stage === 'done' || (i === cur && s.stage === 'done')) return { label, status: 'done' as const, milestones }
293    if (i === cur) {
294      const detail = stage === 'exec' ? `${done}/${totalOf(s)}`
295        : s.stagePhase ? REPORTED_LABELS[s.stagePhase] : undefined
296      return { label, status: 'active' as const, milestones, ...(detail ? { detail } : {}) }
297    }
298    // A stage behind the current one that the run never entered was bypassed, whatever its position: no final review
299    // happened when the run went from exec straight to finishing.
300    const isEntered = s.milestones.some(m => m.stage === stage) || s.stageStartedAt?.[stage] !== undefined
301    if (!isEntered) return { label, status: 'skipped' as const, milestones }
302    const duration = stageDuration(s, i)
303    return { label, status: 'done' as const, milestones, ...(duration ? { duration } : {}) }
304  })
305}
306
307/** The phases still expected after `trail`, by the furthest point of the mode's sequence it reached. */
308function expectedAfter(trail: readonly TaskEvent[], mode: RunState['mode']): TaskPhase[] {
309  const sequence = mode === 'direct' ? INLINE_PHASES : SDD_PHASES
310  const place = (phase: TaskPhase) => sequence.indexOf(mode === 'direct' ? phase : SDD_PLACE[phase] ?? phase)
311  const reached = trail.reduce((furthest, e) => Math.max(furthest, place(e.phase)), -1)
312  return sequence.slice(reached + 1)
313}
314
315function currentOf(s: RunState, t: TaskState): NonNullable<PaneModel['current']> {
316  const last = t.trail.length - 1
317  const passed = t.trail.map((e, i) => ({
318    label: phaseLabel(e), status: i === last ? 'active' as const : 'done' as const, ...(e.verdict ? { verdict: e.verdict } : {}),
319  }))
320  const expected = expectedAfter(t.trail, s.mode).map(phase => ({ label: phaseLabel({ phase, at: 0 }), status: 'todo' as const }))
321  return { n: t.n, title: t.title, trail: [...passed, ...expected] }
322}
323
324/**
325 * The rows the decisions block takes at least: its title, MIN_DECISION_ROWS lines, and the `(+N)` line when there are
326 * more; none without decisions.
327 */
328function decisionReserve(count: number): number {
329  if (count === 0) return 0
330  return DECISION_CHROME_ROWS + Math.min(count, MIN_DECISION_ROWS) + (count > MIN_DECISION_ROWS ? 1 : 0)
331}
332
333/** How many of `count` decisions the `room` rows left show, keeping one for `(+N)` when some stay hidden. */
334function shownDecisions(count: number, room: number): number {
335  if (count <= Math.max(MIN_DECISION_ROWS, room)) return count
336  return Math.max(MIN_DECISION_ROWS, room - 1)
337}
338
339/**
340 * The pane's content for `rows` rows: the task list folds around the current task in the rows left once the
341 * decisions keep their least; the decisions then show their latest in what the tasks leave, so the estimate below
342 * stays in view.
343 */
344export function paneModel(s: RunState, now: number, rows: number): PaneModel {
345  const cur = currentTask(s)
346  const current = cur ? currentOf(s, cur) : undefined
347  const stages = stagesOf(s)
348  const curIndex = cur ? s.tasks.indexOf(cur) : Math.max(0, s.tasks.findIndex(t => t.status !== 'done'))
349  const extraRows = stages.reduce((n, g) => n + g.milestones.length, 0) + (current?.trail.length ?? 0)
350  const free = rows - PANE_CHROME_ROWS - extraRows
351  const { start, end } = visibleWindow(s.tasks.length, free - decisionReserve(s.decisions.length), curIndex)
352  const decisionRoom = free - (end - start) - (s.decisions.length > 0 ? DECISION_CHROME_ROWS : 0)
353  const decisionCount = shownDecisions(s.decisions.length, decisionRoom)
354  const est = estimate(s, now)
355  const elapsed = elapsedOf(s, now)
356  return {
357    title: s.planPath ? `superpowers · ${planSlug(s.planPath)}` : 'superpowers',
358    ...(elapsed ? { elapsed } : {}),
359    stages,
360    tasks: s.tasks.slice(start, end).map(t => ({
361      n: t.n, title: t.title, status: t.status, fixRounds: t.fixRounds,
362      ...(t.status === 'done' && t.startedAt && t.doneAt ? { duration: formatDuration(t.doneAt - t.startedAt) } : {}),
363    })),
364    hiddenTasks: s.tasks.length - (end - start),
365    ...(current ? { current } : {}),
366    decisions: s.decisions.slice(s.decisions.length - decisionCount),
367    hiddenDecisions: s.decisions.length - decisionCount,
368    ...(est ? { eta: `avg ${formatDuration(est.avgMs)}/task · ~${formatDuration(est.remainingMs)} left · ETA ${formatClock(est.finishAt)}` } : {}),
369  }
370}
371
hooks/spinner.tsx 24 lines
1// A surface module, drawn by the band's and the pane's `Client`s: it runs on the surface's own frame clock, so the
2// spinner turns without the plugin's tree being drawn again.
3
4import type { ClientModule } from 'claude-code'
5
6const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
7const FRAME_MS = 100
8
9/** A braille spinner in the theme's `warning` colour, one cell wide; its state is the frame shown. */
10const Spinner: ClientModule<null, number> = (_props, surface) => {
11  if (surface.state === undefined) {
12    let frame = 0
13    surface.every(FRAME_MS, () => {
14      frame = (frame + 1) % FRAMES.length
15      surface.setState(frame)
16    })
17    surface.setState(frame)
18  }
19  const { Text } = surface.elements
20  return <Text color="warning">{FRAMES[surface.state ?? 0]}</Text>
21}
22
23export default Spinner
24
src/detectors/index.ts 35 lines
1import type { ObservedEvent, RunState } from '../../types'
2import { reduce } from '../reduce.ts'
3import { detect } from './sp-7.0.0.ts'
4
5export { detect, MARKERS } from './sp-7.0.0.ts'
6export type { Marker } from './sp-7.0.0.ts'
7
8/**
9 * Rebuilds a run from observed events. Each event's file reads are served from `files`
10 * right after it, as `file` events stamped with its time; paths missing from `files` are skipped.
11 * An array lists the contents successive reads of that path see; its last entry repeats.
12 */
13export function replay(events: ObservedEvent[], files: Record<string, string | readonly string[]> = {}): RunState | null {
14  let s: RunState | null = null
15  const readCounts = new Map<string, number>()
16  const contentOf = (path: string): string | undefined => {
17    if (!Object.hasOwn(files, path)) return undefined
18    const entry = files[path]!
19    if (typeof entry === 'string') return entry
20    const k = readCounts.get(path) ?? 0
21    readCounts.set(path, k + 1)
22    return entry[Math.min(k, entry.length - 1)]
23  }
24  const apply = (e: ObservedEvent) => {
25    const d = detect(e, s)
26    for (const a of d.actions) s = reduce(s, a)
27    for (const r of d.reads) {
28      const text = contentOf(r.path)
29      if (text !== undefined) apply({ kind: 'file', role: r.role, path: r.path, text, at: e.at })
30    }
31  }
32  for (const e of events) apply(e)
33  return s
34}
35