feat(setup): ordinal-suffix repeated spinner captions — (1/2), (2/2)

Two steps under one heading share a heading-derived caption (add-telegram's
build + test both render "5. Build and validate"), which reads as a
stuttered duplicate in the wizard. The driver now pre-computes labelOrdinals
from the parsed document (same pre-parse the gate policy uses) and suffixes
" (i/n)" when a caption repeats — keyed by the directive line the step
events already carry. Pure driver-side presentation; no engine change, no
event-payload change. Solo captions stay unsuffixed; counting is static, so
a runtime-skipped sibling can leave a cosmetic gap in the sequence.

Live stutter observed in the telegram wizard smoke run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-07-04 18:36:40 +03:00
parent 0c020e664d
commit 8a3444a68c
2 changed files with 71 additions and 3 deletions
+42 -2
View File
@@ -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)');
});
});
+29 -1
View File
@@ -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<number, string> {
const byLabel = new Map<string, number[]>();
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<number, string>();
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<void>,
): (e: ApplyEvent) => Promise<void> {
const gates = gatePolicy(md);
const ordinals = labelOrdinals(md);
let active: ReturnType<typeof startSpinner> | 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;
}