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…

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
/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..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.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.▶ marks the first unchecked task in tasks.md. It moves as Claude ticks the boxes.openspec/changes/ with its stage: ◔ Propose, ◑ Apply n/m or ● Sync & Archive (ready).OpenSpec: add-dark-mode · Propose › writing Design. This helps on narrow terminals, where the pane does not dock.PATH (npm i -g @fission-ai/openspec). It was tested with 1.14.0.openspec init.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.
| Command | What it does |
|---|---|
/openspec-tui | Open (or focus) the pane at any width |
/openspec-tui <change> | Pin the pane to one change |
/openspec-tui auto | Follow the latest change again |
The pane updates as you work:
/opsx:* or openspec-* skill runs (typed by you or invoked by Claude);openspec/changes/<name>/;openspec shell command runs;Every color, weight and glyph follows DESIGN.md, which is built for contrast on light and dark themes. A test enforces it.
The pane never parses OpenSpec files itself. Its data comes from the CLI's JSON output:
| Command | What the pane uses |
|---|---|
openspec list --json | every active change and its task counts |
openspec status --change <c> --json | the schema's artifacts, their state and dependencies |
openspec instructions apply --change <c> --json | the task list and progress |
openspec schemas --json | the schema's artifacts before any change exists |
Because the data comes from the CLI, custom schemas work without any changes.
| File | Contents |
|---|---|
hooks/register.tsx | hooks, refreshing, the pane |
hooks/model.ts | pure functions: parsing, phase and step derivation |
types/index.d.ts | the state contract |
tests/ | claude plugin test suites (model and pane, on the terminal and desktop surfaces) |
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
MIT
hooks/register.tsx 392 lines1import { 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}
392hooks/model.ts 351 lines1import 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}
351hooks/ui.tsx 50 lines1// 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}
50types/index.d.ts 67 lines1export 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