feat(cli): add per-stage spinners to mgw:run pipeline steps#164
Conversation
…tors to mgw:run Adds lib/spinner.cjs with createSpinner/withSpinner helpers that show animated stage indicators (|/-\) with elapsed time in TTY mode and plain log lines in non-TTY/CI environments. Integrates spinners into bin/mgw.cjs so mgw:run displays a pipeline stage overview and a live spinner while the command runs. In --quiet mode the spinner runs for the full duration and shows succeed/fail on completion; in streaming mode a brief indicator shows before claude takes the TTY. Closes #120 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
snipcodeit
left a comment
There was a problem hiding this comment.
Review: PR #164 — per-stage spinners for mgw:run
This is clean, well-tested work. The spinner module is properly isolated, respects TTY/CI/NO_COLOR detection, and the 17 unit tests cover the important paths. Approving with a few notes.
🟡 80ms streaming mode spinner is a timing assumption
In bin/mgw.cjs, streaming mode does:
const spinner = createSpinner(`mgw:${stageLabel}`);
spinner.start();
await new Promise(r => setTimeout(r, 80));
spinner.stop();This is essentially a 80ms flash to show something is happening before Claude's streaming output takes over. The 80ms assumption is fine for normal machines, but on a slow system (or if invokeClaude starts a subprocess with some latency), the spinner might stop before the first Claude output appears, leaving a gap.
Consider logging a non-animated stage header instead for streaming mode, since the user will see Claude's output anyway:
// Streaming: just print a stage header, no animation needed
if (!opts.json) {
process.stdout.write(`mgw:${stageLabel} → starting...\n`);
}This avoids the timing assumption entirely. Not blocking, just a suggestion.
🟡 stage parameter mutation in start()
function start(label) {
if (label) stage = label; // mutates the outer closure variable
...
}This mutates the stage closed-over variable. It works, but it means calling spinner.start('override') after spinner.stop() and then spinner.start() again would use the overridden label. Minor, but worth documenting or removing the override parameter if it's not needed.
🟡 lib/index.cjs conflict risk with PR #165
Both this PR and PR #165 add a single line to lib/index.cjs:
These will conflict if merged sequentially without resolving. Whoever merges second needs to include both lines.
✅ Strong points
SUPPORTS_SPINNER = IS_TTY && !IS_CIis exactly right — no surprises in CIclearLine()using\r+ spaces +\ris the correct portable approachwithSpinnerproperly rethrows — error propagation is correct- Non-TTY fallback prints plain
[mgw] stage...lines — good for CI logs - Test coverage is thorough: 17 tests including edge cases and error propagation
- All 127 existing tests still pass
Summary
lib/spinner.cjs— a zero-dependency spinner utility withcreateSpinnerandwithSpinnerhelpers that cycle|/-\frames with elapsed time in TTY mode, falling back to plain log lines in non-TTY/CI environmentsbin/mgw.cjssomgw rundisplays a named pipeline stage overview before handing off to claude, and a live spinner in--quietmode that shows succeed/fail on completioncreateSpinner,withSpinner, andSUPPORTS_SPINNERfromlib/index.cjsbarrel for use by any future pipeline steptest/spinner.test.cjscovering all spinner methods, non-TTY fallback behavior, andwithSpinnererror propagationCloses #120
Milestone Context
Changes
lib/spinner.cjs (new)
createSpinner(stage)— returns{ start, succeed, fail, stop }object; animates at 100ms in TTY, plain lines in non-TTYwithSpinner(stage, fn, opts)— wraps async fn with spinner lifecycle; rethrows on rejectSUPPORTS_SPINNER— boolean export for TTY+non-CI detectionlib/index.cjs
require('./spinner.cjs')to barrel exportbin/mgw.cjs
STAGE_LABELSmap for human-readable stage names per commandRUN_PIPELINE_STAGESarray for pipeline stage bannerrunAiCommand()— shows spinner in--quietmode wrapping the full claude invocation; shows brief stage indicator in streaming moderuncommand action — prints pipeline stage overview (validate → triage → create-worktree → execute-gsd → create-pr) before invoking claudetest/mgw.test.cjs
spinner.cjsto the lib modules existence checktest/spinner.test.cjs (new)
Test Plan
npm test— 110 existing + 17 new spinner tests)createSpinnerstart/succeed/fail/stop methods verified in non-TTY test environmentwithSpinnerresolves return values, emits success output, rethrows on rejectSUPPORTS_SPINNERis correctlyfalsein piped/non-TTY contextslib/index.cjsbarrel exportscreateSpinner,withSpinner,SUPPORTS_SPINNERmgw run <number> --quietand verify spinner appears with elapsed timemgw run <number>(streaming) and verify stage banner prints before claude output