diff --git a/setup/lib/skill-driver.test.ts b/setup/lib/skill-driver.test.ts index ea56452c2..2501bfaab 100644 --- a/setup/lib/skill-driver.test.ts +++ b/setup/lib/skill-driver.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { runSkill, hostExec, hostExecStream, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js'; +import { runSkill, hostExec, hostExecStream, labelOrdinals, promptValidator, clackResolveInput, type RunSkillOptions } from './skill-driver.js'; import { fullyApplied, type ApplyEvent } from '../../scripts/skill-apply.js'; // Shared test state for the clack + claude-handoff mocks (hoisted so the vi.mock @@ -401,3 +401,43 @@ describe('thin skill driver', () => { expect(promptValidator(undefined, undefined, question)).toBeUndefined(); // no regex ⇒ no validator }); }); + +// Two steps under one heading share a spinner caption (build + test both read +// "Build and validate") — the ordinal suffix marks them as a sequence, not a +// stuttered duplicate. Solo captions stay unsuffixed. +describe('labelOrdinals (repeated-caption disambiguation)', () => { + const MD = [ + '# demo', + '', + '## Build and validate', + '```nc:run effect:build', + 'pnpm run build', + '```', + '```nc:run effect:test', + 'pnpm exec vitest run x.test.ts', + '```', + '', + '## Restart', + '```nc:run effect:restart', + 'bash restart.sh', + '```', + '', + ].join('\n'); + + it('suffixes (1/2)/(2/2) on the shared caption and leaves the solo one alone', () => { + const ords = labelOrdinals(MD); + const buildLine = 4; // opening fence of the build run (1-based) + const testLine = 7; + const restartLine = 12; + expect(ords.get(buildLine)).toBe(' (1/2)'); + expect(ords.get(testLine)).toBe(' (2/2)'); + expect(ords.has(restartLine)).toBe(false); // unique caption — no suffix + }); + + it('the real add-telegram build+test pair (the live stutter) gets ordinals', () => { + const md = readFileSync(join(process.cwd(), '.claude/skills/add-telegram/SKILL.md'), 'utf8'); + const suffixes = [...labelOrdinals(md).values()]; + expect(suffixes).toContain(' (1/2)'); + expect(suffixes).toContain(' (2/2)'); + }); +}); diff --git a/setup/lib/skill-driver.ts b/setup/lib/skill-driver.ts index a9e4e3b92..dbc51ebf2 100644 --- a/setup/lib/skill-driver.ts +++ b/setup/lib/skill-driver.ts @@ -22,6 +22,7 @@ import { applySkill, fullyApplied, normalizeValue, + stepLabel, type ApplyEvent, type ApplyResult, type InputMeta, @@ -288,12 +289,38 @@ const GATE_WORDING: Record<'readiness' | 'completed', string> = { completed: "Done with the steps above? Continue when you're ready.", }; +/** + * Where several steps under one heading share a spinner caption (a build and a + * test both labelled "5. Build and validate"), suffix an ordinal — "(1/2)", + * "(2/2)" — so consecutive spinners read as distinct steps, not a stutter. + * Keyed by directive line (the step events carry it). Counting is static + * (document order over the parsed directives); a runtime-skipped sibling can + * leave a gap in the rendered sequence — cosmetic only. + */ +export function labelOrdinals(md: string): Map { + const byLabel = new Map(); + for (const d of parseDirectives(md)) { + const label = stepLabel(d, md); + if (label === null) continue; + const lines = byLabel.get(label) ?? []; + lines.push(d.line); + byLabel.set(label, lines); + } + const out = new Map(); + for (const lines of byLabel.values()) { + if (lines.length < 2) continue; + lines.forEach((ln, i) => out.set(ln, ` (${i + 1}/${lines.length})`)); + } + return out; +} + /** * The driver's DEFAULT `onEvent` handler — the whole wizard presentation policy: * * • step-start/step-end → a per-step clack spinner (built on runner.ts's * `startSpinner`), TTY-gated so piped/CI/test runs stay quiet. A null label * is the engine's instant/renders-its-own-output declaration — no spinner. + * Repeated captions under one heading get an ordinal suffix (labelOrdinals). * • operator → render the block as a clack note, then the URL offer * (extractOfferUrl → confirm → openUrl), then the natural-barrier confirm * when gatePolicy says this block precedes a side-effecting directive. @@ -308,11 +335,12 @@ function defaultOnEvent( open: (url: string) => Promise, ): (e: ApplyEvent) => Promise { const gates = gatePolicy(md); + const ordinals = labelOrdinals(md); let active: ReturnType | null = null; return async (e) => { if (e.type === 'step-start') { if (!process.stdout.isTTY || e.label === null) return; // quiet: non-TTY, or instant/cheap step - const base = e.label.replace(/…+$/, ''); + const base = e.label.replace(/…+$/, '') + (ordinals.get(e.line) ?? ''); active = startSpinner({ running: `${base}…`, done: base, failed: `${base} failed` }); return; }