mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-07-06 18:52:03 +08:00
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:
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user