SLOPSHOPPER

openspec-tui

A live side pane for OpenSpec: where your change is in New/Explore → Propose (each artifact) → Apply → Verify → Sync & Archive, the task being worked on, and…

newpaneguardcommandstatusprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openspec-tui
│ ┃ OpenSpec ✕ › fix the failing auth test and add an audit log call │ ┃ OpenSpec │ ┃ No openspec/ folder here. ⏺ 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 │ │ › /openspec-tui │ ⎿ openspec-tui: OpenSpec pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · OpenSpec
OpenSpec No openspec/ folder here.
README

openspec-tui

A live side pane for OpenSpec inside Claude Code. It shows where your change is in its journey, which artifact Claude is writing, the task being implemented right now, and where every other active change stands.

OpenSpec · add-dark-mode
Schema: spec-driven
  proposal → specs → design → tasks
────────────────────────────────────────
✓ New / Explore
✓ Propose  4/4
  ├─ ✓ Proposal
  ├─ ✓ Specs
  ├─ ✓ Design
  └─ ✓ Tasks
● Apply  4/9
    ███████████░░░░░░░░░░░░░
    … 2 done above
    ✓ 1.3 Add theme token
    ▶ 2.1 Wire toggle into settings
    ○ 2.2 Persist preference
○ Verify
○ Sync & Archive
────────────────────────────────────────
Next: /opsx:apply to work the tasks
────────────────────────────────────────
Working on
  ▸ add-dark-mode  ·  Apply
    from your last OpenSpec command or edit
    last command: /opsx:apply
────────────────────────────────────────
Active changes (3)
  ▸ add-dark-mode  ◑ Apply
    fix-login      ◔ Propose
    tidy-docs      ● Sync & Archive (ready)  [tdd]
  /openspec-tui <name> to follow another

What it shows

  • The journey. It starts with New / Explore, because exploring is optional and /opsx:new alone counts. Then comes Propose, then Apply, then Verify, and it ends with Sync & Archive, because /opsx:sync is optional before /opsx:archive.
  • The active schema. The header names the followed change's schema and its artifact flow. A change can use a schema other than the project default (its .openspec.yaml says which); then the header adds (project default: …), and in Active changes that change gets a [schema] tag. Nothing is hardcoded: the steps, the order, and which artifacts must be done before Apply (the schema's applyRequires) all come from the CLI.
  • Propose, split by your schema. Each artifact the change's schema defines is a sub-step under Propose, in the schema's own order. The default spec-driven schema gives proposal, specs, design and tasks. Each artifact shows one of these marks:
  • ✓: written.
  • ✎ writing…: Claude is writing it now.
  • ● next: the first one ready to write.
  • ○: ready to write.
  • · needs …: blocked on other artifacts.
  • The task in progress. During Apply, ▶ marks the first unchecked task in tasks.md. It moves as Claude ticks the boxes.
  • Working on. Shows the change the pane follows and why it picked that one: you pinned it, your last OpenSpec command or edit named it, or it's the most recently changed.
  • Active changes. Lists active changes only (never anything archived). It shows every change under openspec/changes/ with its stage: ◔ Propose, ◑ Apply n/m or ● Sync & Archive (ready).
  • Status line. Shows something like OpenSpec: add-dark-mode · Propose › writing Design. This helps on narrow terminals, where the pane does not dock.

Requirements

  • A Claude Code build with plugin function hooks (mods). It was built and tested on 2.1.294 and 2.1.295.
  • The OpenSpec CLI on your PATH (npm i -g @fission-ai/openspec). It was tested with 1.14.0.
  • A project initialised with openspec init.

Install

At the prompt of a Claude Code terminal session:

/plugin install openspec-tui --marketplace intent-driven-dev/claude-mods

Answer y to add the marketplace, then pick a scope. The user scope is the default. The pane opens by itself in fullscreen mode when the terminal is at least 144 columns wide.

Usage

CommandWhat it does
/openspec-tuiOpen (or focus) the pane at any width
/openspec-tui <change>Pin the pane to one change
/openspec-tui autoFollow the latest change again

The pane updates as you work:

  • when an /opsx:* or openspec-* skill runs (typed by you or invoked by Claude);
  • when Claude writes a file under openspec/changes/<name>/;
  • when an openspec shell command runs;
  • at the end of every turn;
  • every 10 seconds, to catch edits made outside Claude.

Design

Every color, weight and glyph follows DESIGN.md, which is built for contrast on light and dark themes. A test enforces it.

How it works

The pane never parses OpenSpec files itself. Its data comes from the CLI's JSON output:

CommandWhat the pane uses
openspec list --jsonevery active change and its task counts
openspec status --change <c> --jsonthe schema's artifacts, their state and dependencies
openspec instructions apply --change <c> --jsonthe task list and progress
openspec schemas --jsonthe schema's artifacts before any change exists

Because the data comes from the CLI, custom schemas work without any changes.

FileContents
hooks/register.tsxhooks, refreshing, the pane
hooks/model.tspure functions: parsing, phase and step derivation
types/index.d.tsthe state contract
tests/claude plugin test suites (model and pane, on the terminal and desktop surfaces)

Development

claude --plugin-dir ./plugins/openspec-tui   # run it from this folder; saves hot-reload
claude plugin validate ./plugins/openspec-tui
claude plugin test ./plugins/openspec-tui
tsc -p ./plugins/openspec-tui                 # after one load has laid .claude-plugin/types

License

MIT

Source 4 files
hooks/register.tsx 392 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { OpenSpecActivity, OpenSpecSchema, OpenSpecSnapshot } from '../types'
5import {
6  type Step,
7  artifactForPath,
8  buildSteps,
9  changeFromPath,
10  changeStage,
11  chooseActive,
12  currentTaskIndex,
13  derivePhase,
14  isOpenSpecSkill,
15  liveStep,
16  parseActive,
17  parseJson,
18  parseList,
19  parseSchemas,
20  progressBar,
21  schemaFlow,
22  schemaFromConfig,
23  statusLine,
24  taskWindow,
25} from './model'
26import { type Tone, kit } from './ui'
27
28const PANE = 'openspec-tui'
29const COMMAND = 'openspec-tui'
30const TICK_MS = 400
31const POLL_MS = 10000
32
33const snapshot = atom({ plugin: 'openspec-tui', key: 'snapshot' } as const, null)
34const pinned = atom({ plugin: 'openspec-tui', key: 'pinned' } as const, null)
35const activity = atom({ plugin: 'openspec-tui', key: 'activity' } as const, {})
36
37const NEXT_HINT: Record<string, string> = {
38  explore: '/opsx:new to start (or /opsx:explore first, or /opsx:propose for everything at once)',
39  plan: '/opsx:continue for the next artifact',
40  apply: '/opsx:apply to work the tasks',
41  verify: '/opsx:verify, then /opsx:sync or /opsx:archive',
42  archive: '/opsx:sync (optional), then /opsx:archive',
43}
44
45// Refreshing runs on session.start's own `$`, from its timers: a hook only asks
46// for one (requestRefresh), so no work outlives the dispatch that asked.
47let isRefreshing = false
48let isDirty = true
49/** The default schema's artifacts, fetched again only when `config.yaml` names another. */
50let schemaCache: OpenSpecSchema | undefined
51
52function requestRefresh() {
53  isDirty = true
54}
55
56async function openspec($: EngineInterface, args: string[]) {
57  const { stdout } = await $.process.run(['openspec', ...args, '--json'], { timeoutMs: 20000 })
58  return parseJson(stdout)
59}
60
61async function refreshOnce($: EngineInterface) {
62  const now = await $.clock.now()
63  let next: OpenSpecSnapshot
64  try {
65    const list = parseList(await openspec($, ['list']))
66    const { hasRoot, rootPath } = list
67    const readText = (path: string) => $.fs.read(path).catch(() => '')
68
69    // The project default (cheap to re-read, so edits to config.yaml show up)
70    // and each change's own schema from its .openspec.yaml.
71    const defaultSchema = rootPath
72      ? schemaFromConfig(await readText(`${rootPath}/openspec/config.yaml`))
73      : undefined
74    const changes = rootPath
75      ? await Promise.all(
76          list.changes.map(async c => ({
77            ...c,
78            schemaName:
79              schemaFromConfig(await readText(`${rootPath}/openspec/changes/${c.name}/.openspec.yaml`)) ??
80              defaultSchema,
81          })),
82        )
83      : list.changes
84
85    const name = hasRoot
86      ? chooseActive(changes, await read($, pinned), (await read($, activity)).hintedChange)
87      : undefined
88    let active
89    if (name) {
90      const [status, apply] = await Promise.all([
91        openspec($, ['status', '--change', name]),
92        openspec($, ['instructions', 'apply', '--change', name]),
93      ])
94      active = parseActive(name, status, apply)
95    }
96    if (hasRoot && (schemaCache === undefined || schemaCache.name !== defaultSchema)) {
97      schemaCache = parseSchemas(await openspec($, ['schemas']), defaultSchema)
98    }
99    next = {
100      hasRoot,
101      changes,
102      active,
103      schema: schemaCache,
104      defaultSchema: defaultSchema ?? schemaCache?.name,
105      refreshedAt: now,
106    }
107  } catch (err) {
108    // Keep what the last good refresh saw, never more: the followed change
109    // only while that list still has it.
110    const prev = await read($, snapshot)
111    const changes = prev?.changes ?? []
112    next = {
113      hasRoot: prev?.hasRoot ?? false,
114      changes,
115      active: changes.some(c => c.name === prev?.active?.name) ? prev?.active : undefined,
116      schema: prev?.schema,
117      defaultSchema: prev?.defaultSchema,
118      error: err instanceof Error ? err.message : String(err),
119      refreshedAt: now,
120    }
121  }
122  await update($, snapshot, () => next)
123  const act = await read($, activity)
124  $.ui.status(statusLine(next.active, buildSteps(next.active, act, next.schema)))
125}
126
127/** Runs the refreshes asked for since the last one; never two at once. */
128async function tick($: EngineInterface) {
129  if (!isDirty || isRefreshing) return
130  isRefreshing = true
131  try {
132    while (isDirty) {
133      isDirty = false
134      await refreshOnce($)
135    }
136  } finally {
137    isRefreshing = false
138  }
139}
140
141function setActivity($: EngineInterface, patch: Partial<OpenSpecActivity>) {
142  return update($, activity, prev => ({ ...prev, ...patch }))
143}
144
145/** `/opsx:apply add-auth` → `add-auth`, when the first word looks like a change name. */
146function changeFromArgs(args: string | undefined): string | undefined {
147  const first = (args ?? '').trim().split(/\s+/)[0]
148  return first && /^[a-z0-9][a-z0-9-]*$/.test(first) ? first : undefined
149}
150
151export const register: Register = on => {
152  on('session.start', async ($, e, next) => {
153    await $.command.register({
154      name: COMMAND,
155      description: 'Show the OpenSpec pane. Args: <change> to pin one, auto to follow the latest',
156    })
157    void $.ui.open({ id: PANE, title: 'OpenSpec' })
158    isDirty = true
159    $.clock.every(TICK_MS, () => void tick($))
160    $.clock.every(POLL_MS, requestRefresh)
161
162    return next(e)
163  })
164
165  on('command.run', { command: COMMAND }, async ($, e) => {
166    const arg = e.args.trim()
167    if (arg === 'auto') {
168      await update($, pinned, () => null)
169    } else if (arg !== '') {
170      await update($, pinned, () => arg)
171    }
172    await $.ui.open({ id: PANE, title: 'OpenSpec' })
173    isDirty = true
174    await tick($)
175    const snap = await read($, snapshot)
176    if (arg !== '' && arg !== 'auto' && !snap?.changes.some(c => c.name === arg)) {
177      return { text: `No active change named "${arg}"; following the latest instead.` }
178    }
179    return {
180      text: snap?.active ? `OpenSpec pane following ${snap.active.name}.` : 'OpenSpec pane opened.',
181    }
182  })
183
184  // The person typing /opsx:apply <change> (or /openspec-apply-change ...).
185  on('command.run', async ($, e, next) => {
186    if (isOpenSpecSkill(e.command)) {
187      await setActivity($, {
188        skill: e.command,
189        hintedChange: changeFromArgs(e.args) ?? (await read($, activity)).hintedChange,
190      })
191      requestRefresh()
192    }
193    return next(e)
194  })
195
196  on('tool.call', async ($, e, next) => {
197    // The model invoking an OpenSpec skill itself.
198    if (e.tool === 'Skill' && isOpenSpecSkill(e.skill)) {
199      await setActivity($, {
200        skill: e.skill,
201        hintedChange: changeFromArgs(e.args) ?? (await read($, activity)).hintedChange,
202      })
203      requestRefresh()
204      return next(e)
205    }
206
207    if (e.tool === 'Write' || e.tool === 'Edit') {
208      const path = e.file_path
209      const change = changeFromPath(path)
210      if (change === undefined) return next(e)
211
212      const snap = await read($, snapshot)
213      const active = snap?.active?.name === change ? snap.active : undefined
214      await setActivity($, {
215        hintedChange: change,
216        writingArtifact: artifactForPath(path, active),
217      })
218      const ran = await next(e)
219      requestRefresh()
220      return ran
221    }
222
223    if (e.tool === 'Bash' && /(^|[\s;&|(])openspec\s/.test(e.command)) {
224      const ran = await next(e)
225      requestRefresh()
226      return ran
227    }
228
229    return next(e)
230  })
231
232  on('turn.complete', async ($, e, next) => {
233    await setActivity($, { writingArtifact: undefined })
234    requestRefresh()
235    return next(e)
236  })
237
238  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
239    const els = $.ui.resolve(e)
240    const { Box } = els
241    const width = Math.max(20, e.props.bodyColumns)
242    const ui = kit(els, width)
243    return <Box flexDirection="column">{await renderJourney($, ui, width, e.props.scroll.bodyRows)}</Box>
244  })
245}
246
247type Kit = ReturnType<typeof kit>
248
249/** Where the followed change is in its journey. */
250async function renderJourney($: EngineInterface, ui: Kit, width: number, bodyRows: number | undefined) {
251  const { tone, rule, Box } = ui
252  const snap = await read($, snapshot)
253  const act = await read($, activity)
254
255  if (snap === null) {
256    return tone('meta', 'Reading OpenSpec…')
257  }
258  if (!snap.hasRoot) {
259    return (
260      <Box flexDirection="column">
261        {tone('title', 'OpenSpec')}
262        {tone('meta', 'No openspec/ folder here.')}
263        {snap.error !== undefined && tone('error', snap.error)}
264      </Box>
265    )
266  }
267
268  const active = snap.active
269  const phase = derivePhase(active, act)
270  const steps = buildSteps(active, act, snap.schema)
271  const pin = await read($, pinned)
272  const tasks = active?.tasks ?? []
273  const current = currentTaskIndex(tasks)
274  // The rows drawn around the task list.
275  const used = 5 + steps.length + 4 + 5 + 2 + snap.changes.length
276  const room = Math.max(5, (bodyRows ?? 24) - used)
277  const win = taskWindow(tasks, room)
278  const schemaName = active?.schemaName || snap.schema?.name
279  const flow = schemaFlow(active ? active.artifacts.map(a => a.id) : (snap.schema?.artifacts ?? []))
280  const isOffDefault =
281    schemaName !== undefined && snap.defaultSchema !== undefined && schemaName !== snap.defaultSchema
282  const why = !active
283    ? undefined
284    : pin === active.name
285      ? `pinned · /${COMMAND} auto to unpin`
286      : act.hintedChange === active.name
287        ? 'from your last OpenSpec command or edit'
288        : 'most recently changed'
289  const nameWidth = Math.min(
290    Math.max(...snap.changes.map(c => c.name.length), 4),
291    Math.max(8, width - 26),
292  )
293  const live = liveStep(steps)
294
295  const mark = (s: Step) =>
296    ({ done: '✓', current: '●', writing: '✎', ready: '○', blocked: '·', pending: '○' })[s.state]
297  const stepTone = (s: Step): Tone =>
298    ({ done: 'done', current: 'current', writing: 'writing', ready: 'body', blocked: 'muted', pending: 'muted' })[
299      s.state
300    ] as Tone
301
302  return (
303    <Box flexDirection="column">
304      {tone('title', `OpenSpec${active ? ` · ${active.name}` : ''}`)}
305      {schemaName && (
306        <Box flexDirection="row">
307          {tone('body', 'Schema: ')}
308          {tone('accent', schemaName)}
309          {isOffDefault && tone('meta', `  (project default: ${snap.defaultSchema})`)}
310        </Box>
311      )}
312      {flow !== '' && tone('meta', `  ${flow}`, 'wrap')}
313      {rule()}
314
315      {steps.map(s => {
316        const line = tone(stepTone(s), `${mark(s)} ${s.label}${s.note ? `  ${s.note}` : ''}`)
317        if (s.kind === 'artifact') {
318          // A sub-step of Propose, drawn as a branch of its tree.
319          return (
320            <Box flexDirection="row">
321              {tone('rule', s.isLastChild ? '  └─ ' : '  ├─ ')}
322              {line}
323            </Box>
324          )
325        }
326        return (
327          <Box flexDirection="column">
328            {line}
329
330            {s.id === 'apply' && s.kind === 'phase' && tasks.length > 0 && (
331              <Box flexDirection="column">
332                {tone(
333                  'meta',
334                  `    ${progressBar(active?.progress.complete ?? 0, active?.progress.total ?? 0, Math.min(24, width - 6))}`,
335                )}
336                {win.start > 0 && tone('meta', `    … ${win.start} done above`)}
337                {tasks.slice(win.start, win.end).map((t, j) => {
338                  const isCurrent = win.start + j === current && phase === 'apply'
339                  return tone(
340                    isCurrent ? 'current' : t.done ? 'done' : 'body',
341                    `    ${isCurrent ? '▶' : t.done ? '✓' : '○'} ${t.description}`,
342                  )
343                })}
344                {win.end < tasks.length && tone('meta', `    … ${tasks.length - win.end} more`)}
345              </Box>
346            )}
347          </Box>
348        )
349      })}
350
351      {rule()}
352      {active?.applyState === 'blocked' && phase === 'apply' && tone('warn', 'Apply is blocked: missing artifacts')}
353      {tone('meta', `Next: ${NEXT_HINT[phase]}`, 'wrap')}
354
355      {rule()}
356      {tone('heading', 'Working on')}
357      {active ? (
358        <Box flexDirection="column">
359          {tone(
360            'current',
361            `  ▸ ${active.name}${live ? `  ·  ${live.state === 'writing' ? 'writing ' : ''}${live.label}` : ''}`,
362          )}
363          {why && tone('meta', `    ${why}`)}
364          {act.skill !== undefined && tone('meta', `    last command: /${act.skill}`)}
365        </Box>
366      ) : (
367        tone('meta', '  No active change yet')
368      )}
369
370      {rule()}
371      {tone('heading', `Active changes (${snap.changes.length})`)}
372      {snap.changes.length === 0 && tone('meta', '  none')}
373      {snap.changes.map(c => {
374        const isFollowed = c.name === active?.name
375        const stage = changeStage(c)
376        const label = isFollowed ? (live?.label ?? stage.label) : stage.label
377        const meter = ['◔', '◑', '●'][stage.rank]
378        const tag = c.schemaName && c.schemaName !== snap.defaultSchema ? `  [${c.schemaName}]` : ''
379        const name = c.name.length > nameWidth ? c.name.slice(0, nameWidth - 1) + '…' : c.name.padEnd(nameWidth)
380        return (
381          <Box flexDirection="row">
382            {tone(isFollowed ? 'current' : 'body', `${isFollowed ? '  ▸ ' : '    '}${name}  `)}
383            {tone(stage.rank === 2 ? 'done' : isFollowed ? 'current' : 'meta', `${meter} ${label}${tag}`)}
384          </Box>
385        )
386      })}
387      {snap.changes.length > 1 && tone('meta', `  /${COMMAND} <name> to follow another`)}
388      {snap.error !== undefined && tone('error', snap.error)}
389    </Box>
390  )
391}
392
hooks/model.ts 351 lines
1import type {
2  OpenSpecActiveChange,
3  OpenSpecActivity,
4  OpenSpecArtifact,
5  OpenSpecChangeSummary,
6  OpenSpecSchema,
7  OpenSpecTask,
8} from '../types'
9
10export const PHASES = ['explore', 'plan', 'apply', 'verify', 'archive'] as const
11export type Phase = (typeof PHASES)[number]
12
13/**
14 * A change starts with `/opsx:new` or `/opsx:explore` (exploring is optional),
15 * and ends with `/opsx:sync` (optional) and `/opsx:archive`.
16 */
17export const PHASE_LABELS: Record<Phase, string> = {
18  explore: 'New / Explore',
19  plan: 'Propose',
20  apply: 'Apply',
21  verify: 'Verify',
22  archive: 'Sync & Archive',
23}
24
25const FINISHING_VERBS = new Set(['sync', 'archive', 'bulk-archive'])
26
27type Json = Record<string, unknown>
28
29const asArray = (value: unknown): Json[] =>
30  Array.isArray(value) ? (value.filter(v => v && typeof v === 'object') as Json[]) : []
31
32/** The CLI prints JSON on stdout, sometimes after a line of noise. */
33export function parseJson(text: string): Json | undefined {
34  const starts = [text.indexOf('{'), text.indexOf('[')].filter(i => i >= 0)
35  if (starts.length === 0) return undefined
36  try {
37    const value: unknown = JSON.parse(text.slice(Math.min(...starts)))
38    return Array.isArray(value) ? { items: value } : (value as Json)
39  } catch {
40    return undefined
41  }
42}
43
44/** `openspec schemas --json`: the one `config.yaml` names, else the first. */
45export function parseSchemas(json: Json | undefined, preferred: string | undefined): OpenSpecSchema | undefined {
46  const all = asArray(json?.items).map(s => ({
47    name: String(s.name ?? ''),
48    artifacts: Array.isArray(s.artifacts) ? s.artifacts.map(String) : [],
49  }))
50  return all.find(s => s.name === preferred) ?? all[0]
51}
52
53/** The `schema:` line of `openspec/config.yaml`, or of a change's `.openspec.yaml`. */
54export function schemaFromConfig(yaml: string): string | undefined {
55  return /^schema:\s*["']?([\w.-]+)/m.exec(yaml)?.[1]
56}
57
58/** The artifact ids in order, as one line: `proposal → specs → design → tasks`. */
59export function schemaFlow(artifacts: readonly string[]): string {
60  return artifacts.join(' → ')
61}
62
63/** `openspec list --json` */
64export function parseList(json: Json | undefined): {
65  hasRoot: boolean
66  rootPath?: string
67  changes: OpenSpecChangeSummary[]
68} {
69  if (!json || json.root === null || typeof json.root !== 'object') {
70    return { hasRoot: false, changes: [] }
71  }
72  const rootPath = (json.root as Json).path
73  const changes = asArray(json.changes).map(c => ({
74    name: String(c.name ?? ''),
75    completedTasks: Number(c.completedTasks ?? 0),
76    totalTasks: Number(c.totalTasks ?? 0),
77    status: String(c.status ?? ''),
78  }))
79  return {
80    hasRoot: true,
81    rootPath: typeof rootPath === 'string' ? rootPath : undefined,
82    // Active changes only: never the archive folder or an archived entry, whatever the CLI version.
83    changes: changes.filter(c => c.name !== '' && c.name !== 'archive' && c.status !== 'archived'),
84  }
85}
86
87/** `openspec status --change <c> --json` plus `openspec instructions apply --change <c> --json` */
88export function parseActive(
89  name: string,
90  status: Json | undefined,
91  apply: Json | undefined,
92): OpenSpecActiveChange {
93  const artifacts: OpenSpecArtifact[] = asArray(status?.artifacts).map(a => ({
94    id: String(a.id ?? ''),
95    outputPath: String(a.outputPath ?? ''),
96    status: String(a.status ?? ''),
97    requires: Array.isArray(a.requires) ? a.requires.map(String) : [],
98  }))
99  const tasks: OpenSpecTask[] = asArray(apply?.tasks).map(t => ({
100    id: String(t.id ?? ''),
101    description: String(t.description ?? ''),
102    done: t.done === true,
103  }))
104  const progress = (apply?.progress ?? {}) as Json
105  return {
106    name,
107    schemaName: String(status?.schemaName ?? ''),
108    changeRoot: String(status?.changeRoot ?? ''),
109    artifacts,
110    isPlanningComplete: status?.isPlanningComplete === true,
111    isComplete: status?.isComplete === true,
112    applyRequires: Array.isArray(status?.applyRequires)
113      ? status.applyRequires.map(String)
114      : artifacts.slice(-1).map(a => a.id),
115    tasks,
116    progress: {
117      total: Number(progress.total ?? tasks.length),
118      complete: Number(progress.complete ?? tasks.filter(t => t.done).length),
119    },
120    applyState: typeof apply?.state === 'string' ? apply.state : undefined,
121    nextSteps: Array.isArray(status?.nextSteps) ? status.nextSteps.map(String) : [],
122  }
123}
124
125/** Which change the pane follows: pinned, else the one last named, else the most recent. */
126export function chooseActive(
127  changes: OpenSpecChangeSummary[],
128  pinned: string | null,
129  hinted: string | undefined,
130): string | undefined {
131  const names = new Set(changes.map(c => c.name))
132  if (pinned && names.has(pinned)) return pinned
133  if (hinted && names.has(hinted)) return hinted
134  return changes[0]?.name
135}
136
137/** The skill families: `opsx:apply` and `openspec-apply-change` are both `apply`. */
138export function skillVerb(skill: string | undefined): string | undefined {
139  if (!skill) return undefined
140  const m = /^(?:opsx:|openspec-)([a-z-]+?)(?:-change|-specs)?$/.exec(skill)
141  return m?.[1]
142}
143
144export function isOpenSpecSkill(name: string): boolean {
145  return /^(opsx:|openspec-)/.test(name)
146}
147
148export function derivePhase(
149  active: OpenSpecActiveChange | undefined,
150  activity: OpenSpecActivity,
151): Phase {
152  const verb = skillVerb(activity.skill)
153  const isFinishing = verb !== undefined && FINISHING_VERBS.has(verb)
154  if (!active) return isFinishing ? 'archive' : 'explore'
155  // A verify, sync or archive run speaks for the change it named, not for whichever comes next.
156  const isAboutThis = activity.hintedChange === undefined || activity.hintedChange === active.name
157  if (isAboutThis && isFinishing) return 'archive'
158  if (isAboutThis && verb === 'verify') return 'verify'
159  // Planning lasts until the artifacts the schema gates apply on are written.
160  const isGateOpen = active.applyRequires.every(
161    id => active.artifacts.find(a => a.id === id)?.status === 'done',
162  )
163  const { total, complete } = active.progress
164  if (!isGateOpen) return 'plan'
165  if (total === 0) return active.isPlanningComplete ? 'apply' : 'plan'
166  if (complete < total) return 'apply'
167  return 'verify'
168}
169
170export type ChangeStage = {
171  label: string
172  /** 0 Propose, 1 Apply, 2 ready to sync and archive: for a small meter. */
173  rank: 0 | 1 | 2
174}
175
176/**
177 * Where a change stands, from `openspec list` alone (no extra CLI call per
178 * change): no tasks yet is Propose, tasks open is Apply, all done is ready to
179 * sync and archive.
180 */
181export function changeStage(c: OpenSpecChangeSummary): ChangeStage {
182  if (c.totalTasks === 0) return { label: PHASE_LABELS.plan, rank: 0 }
183  if (c.completedTasks < c.totalTasks) {
184    return { label: `${PHASE_LABELS.apply} ${c.completedTasks}/${c.totalTasks}`, rank: 1 }
185  }
186  return { label: `${PHASE_LABELS.archive} (ready)`, rank: 2 }
187}
188
189/** The task Claude works on now: the first one not done. */
190export function currentTaskIndex(tasks: OpenSpecTask[]): number {
191  return tasks.findIndex(t => !t.done)
192}
193
194/** Which change a path under `openspec/changes/<name>/` belongs to (archived ones excluded). */
195export function changeFromPath(path: string): string | undefined {
196  const m = /\/openspec\/changes\/([^/]+)\//.exec(path)
197  if (!m || m[1] === 'archive') return undefined
198  return m[1]
199}
200
201/** Which artifact a written file is, by the schema's `outputPath` (`proposal.md`, `specs/**\/*.md`). */
202export function artifactForPath(
203  path: string,
204  active: OpenSpecActiveChange | undefined,
205): string | undefined {
206  if (!active || !active.changeRoot) return undefined
207  const root = active.changeRoot.endsWith('/') ? active.changeRoot : active.changeRoot + '/'
208  if (!path.startsWith(root)) return undefined
209  const rel = path.slice(root.length)
210  for (const a of active.artifacts) {
211    const star = a.outputPath.indexOf('*')
212    if (star < 0 ? rel === a.outputPath : rel.startsWith(a.outputPath.slice(0, star))) {
213      return a.id
214    }
215  }
216  return undefined
217}
218
219/** A window of the task list that keeps the current task in view. */
220export function taskWindow(
221  tasks: OpenSpecTask[],
222  room: number,
223): { start: number; end: number } {
224  const size = Math.max(1, room)
225  if (tasks.length <= size) return { start: 0, end: tasks.length }
226  const cur = currentTaskIndex(tasks)
227  const anchor = cur < 0 ? tasks.length - 1 : cur
228  const start = Math.max(0, Math.min(anchor - 2, tasks.length - size))
229  return { start, end: start + size }
230}
231
232export function progressBar(complete: number, total: number, width: number): string {
233  const w = Math.max(4, width)
234  const filled = total === 0 ? 0 : Math.round((complete / total) * w)
235  return '█'.repeat(filled) + '░'.repeat(w - filled)
236}
237
238export type StepState = 'done' | 'current' | 'writing' | 'ready' | 'blocked' | 'pending'
239
240export type Step = {
241  id: string
242  label: string
243  kind: 'phase' | 'artifact'
244  state: StepState
245  note?: string
246  /** An artifact is a sub-step of Propose; the last one closes the branch. */
247  isLastChild?: boolean
248}
249
250const titleCase = (id: string) => id.charAt(0).toUpperCase() + id.slice(1)
251
252/**
253 * The journey as one list: Explore, then every artifact the schema defines
254 * in its own order, then Apply, Verify and Archive.
255 */
256export function buildSteps(
257  active: OpenSpecActiveChange | undefined,
258  activity: OpenSpecActivity,
259  schema: OpenSpecSchema | undefined,
260): Step[] {
261  const phase = derivePhase(active, activity)
262  const at = PHASES.indexOf(phase)
263  const phaseStep = (p: Phase, note?: string): Step => {
264    const i = PHASES.indexOf(p)
265    return {
266      id: p,
267      label: PHASE_LABELS[p],
268      kind: 'phase',
269      state: i < at ? 'done' : i === at ? 'current' : 'pending',
270      note,
271    }
272  }
273
274  const artifacts: Step[] = active
275    ? active.artifacts.map(a => {
276        const missing = a.requires.filter(
277          r => active.artifacts.find(o => o.id === r)?.status !== 'done',
278        )
279        const state: StepState =
280          activity.writingArtifact === a.id
281            ? 'writing'
282            : a.status === 'done'
283              ? 'done'
284              : a.status === 'ready'
285                ? 'ready'
286                : 'blocked'
287        const note =
288          state === 'writing'
289            ? 'writing…'
290            : state === 'blocked' && missing.length > 0
291              ? `needs ${missing.join(', ')}`
292              : undefined
293        return { id: a.id, label: titleCase(a.id), kind: 'artifact', state, note }
294      })
295    : (schema?.artifacts ?? []).map(id => ({
296        id,
297        label: titleCase(id),
298        kind: 'artifact' as const,
299        state: 'pending' as const,
300      }))
301
302  // While planning, the artifact being written is the current step; failing
303  // that, the first one ready to write is next.
304  if (phase === 'plan' && !artifacts.some(a => a.state === 'writing')) {
305    const next = artifacts.find(a => a.state === 'ready')
306    if (next) {
307      next.state = 'current'
308      next.note = 'next'
309    }
310  }
311
312  const progress =
313    active && active.progress.total > 0
314      ? `${active.progress.complete}/${active.progress.total}`
315      : undefined
316  const last = artifacts[artifacts.length - 1]
317  if (last) last.isLastChild = true
318  const written = artifacts.filter(a => a.state === 'done').length
319  const propose = phaseStep('plan', artifacts.length > 0 ? `${written}/${artifacts.length}` : undefined)
320  return [
321    phaseStep('explore'),
322    propose,
323    ...artifacts,
324    phaseStep('apply', progress),
325    phaseStep('verify'),
326    phaseStep('archive'),
327  ]
328}
329
330/** The step happening now: the artifact being written (or next) says more than "Propose" does. */
331export function liveStep(steps: Step[]): Step | undefined {
332  return (
333    steps.find(s => s.state === 'writing') ??
334    steps.find(s => s.kind === 'artifact' && s.state === 'current') ??
335    steps.find(s => s.state === 'current')
336  )
337}
338
339export function statusLine(
340  active: OpenSpecActiveChange | undefined,
341  steps: Step[],
342): string | undefined {
343  if (!active) return undefined
344  const now = liveStep(steps)
345  if (!now) return `OpenSpec: ${active.name}`
346  const where = now.kind === 'artifact' ? 'Propose › ' : ''
347  const verb = now.state === 'writing' ? 'writing ' : ''
348  const count = now.kind === 'phase' && now.note ? ' ' + now.note : ''
349  return `OpenSpec: ${active.name} · ${where}${verb}${now.label}${count}`
350}
351
hooks/ui.tsx 50 lines
1// The pane's design system: every Text the plugin draws goes through `tone`,
2// so each carries an explicit theme color and none is drawn dim. DESIGN.md
3// gives the rules; tests/pane.test.tsx holds every drawn Text to them.
4
5/** What a piece of text means, not how it looks. */
6export type Tone =
7  | 'title'
8  | 'heading'
9  | 'body'
10  | 'meta'
11  | 'muted'
12  | 'current'
13  | 'done'
14  | 'writing'
15  | 'warn'
16  | 'error'
17  | 'accent'
18  | 'rule'
19
20/** Theme keys only: they follow the person's theme (light, dark, daltonized, ANSI). */
21export const TONES: Record<Tone, { color: string; bold?: true }> = {
22  title: { color: 'claude', bold: true },
23  heading: { color: 'text', bold: true },
24  body: { color: 'text' },
25  meta: { color: 'subtle' },
26  muted: { color: 'inactive' },
27  current: { color: 'claude', bold: true },
28  done: { color: 'success' },
29  writing: { color: 'warning', bold: true },
30  warn: { color: 'warning' },
31  error: { color: 'error' },
32  accent: { color: 'suggestion', bold: true },
33  rule: { color: 'subtle' },
34}
35
36type Elements = { Box: any; Text: any }
37type Wrap = 'wrap' | 'truncate-end'
38
39/** The drawing kit for one render: the surface's elements, styled by tone only. */
40export function kit({ Box, Text }: Elements, width: number) {
41  const tone = (t: Tone, children: unknown, wrap: Wrap = 'truncate-end') => (
42    <Text color={TONES[t].color} bold={TONES[t].bold} wrap={wrap}>
43      {children}
44    </Text>
45  )
46  const rule = () => tone('rule', '─'.repeat(Math.min(width, 48)))
47
48  return { Box, tone, rule }
49}
50
types/index.d.ts 67 lines
1export type OpenSpecArtifact = {
2  id: string
3  outputPath: string
4  /** `done`, `ready` (its inputs are done) or `blocked`. */
5  status: string
6  requires: string[]
7}
8
9export type OpenSpecSchema = { name: string; artifacts: string[] }
10
11export type OpenSpecTask = { id: string; description: string; done: boolean }
12
13export type OpenSpecChangeSummary = {
14  name: string
15  completedTasks: number
16  totalTasks: number
17  status: string
18  /** From the change's `.openspec.yaml`; each change may use its own schema. */
19  schemaName?: string
20}
21
22export type OpenSpecActiveChange = {
23  name: string
24  schemaName: string
25  changeRoot: string
26  artifacts: OpenSpecArtifact[]
27  isPlanningComplete: boolean
28  isComplete: boolean
29  /** The artifacts the schema says must be done before apply (`applyRequires`). */
30  applyRequires: string[]
31  tasks: OpenSpecTask[]
32  progress: { total: number; complete: number }
33  applyState?: string
34  nextSteps: string[]
35}
36
37export type OpenSpecSnapshot = {
38  hasRoot: boolean
39  changes: OpenSpecChangeSummary[]
40  active?: OpenSpecActiveChange
41  /** The project's schema, for drawing the steps before any change exists. */
42  schema?: OpenSpecSchema
43  /** The project's default schema, from `openspec/config.yaml`. */
44  defaultSchema?: string
45  error?: string
46  refreshedAt: number
47}
48
49export type OpenSpecActivity = {
50  /** The last OpenSpec skill or command run, e.g. `opsx:apply`. */
51  skill?: string
52  /** The change that skill, or the last file Claude wrote, named. */
53  hintedChange?: string
54  /** The artifact id Claude is writing in this turn. */
55  writingArtifact?: string
56}
57
58declare module 'claude-code' {
59  interface PluginState {
60    'openspec-tui': {
61      snapshot: OpenSpecSnapshot | null
62      pinned: string | null
63      activity: OpenSpecActivity
64    }
65  }
66}
67