feat(ui): add bubbletea-backed spinner for long-running steps#117
Merged
Conversation
Wraps bubbletea in a small Spinner interface so internal/cli's upgrade flow can show animated progress while nodejs.org is being fetched, Node tarballs are downloading, and global npm packages are being restored. Plain-mode callers get a one-shot "label ..." line per operation so log aggregators see one record each (no cursor-positioned updates to corrupt CI dashboards). Adds bubbletea v1.3.10 to go.mod. The bubbletea dependency is contained to internal/ui/spinner.go — internal/cli calls ui.NewSpinner + ui.WaitWithSpinner and never imports bubbletea directly (same constraint as lipgloss per #105). Implementation notes: - spinner.go: Spinner interface (Start/Mode); plainSpinner prints "label ..." and Stop is a no-op; fancySpinner wraps a tea.Program that rotates through braille-pattern frames on an 80ms tick. The fancy spinner pipes its tea output through a transient stderr handle so frames don't interleave with Writer.Success lines from the same operation. - WaitWithSpinner(ctx, s, work) is the production-call-site helper. Honors ctx cancellation (returns ctx.Err() but does NOT preempt work — the caller is responsible for threading ctx into work itself if preemption is needed). Treats nil spinner as "no spinner" so tests don't have to set one up. - spinner_test.go covers: plain-mode label emission, fancy-mode decision, nil-out writer degradation, WaitWithSpinner happy path, work-error propagation, ctx-cancel returns ctx.Err(). All pass under -race. - upgrade.go: spinnerFor(cmd, label) constructs the spinner matching the active ui.Writer's mode. Manifest fetch, per-version snapshot, per-version install, SetDefault, and global-package restore all run under WaitWithSpinner. Errors flow through unchanged; spinner labels are debuggable from the per-line log output (e.g., "Snapshotting globals (v22.10.0) ..."). Per #105 phasing, this is PR2 of 3. PR3 adds huh prompts + ResolveInteractive; PR4 migrates the remaining call sites (check/list/packages/config) to ui.Writer. PR2 alone keeps the spinner behind ui.NewSpinner so subsequent PRs can adopt it without revisiting this file. Part of #105. Co-Authored-By: puku-ai-2.8 <noreply@puku.sh>
|
✔️ 19ed51e - Conventional commits check succeeded. |
This was referenced Jul 5, 2026
dipto0321
added a commit
that referenced
this pull request
Jul 5, 2026
16 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wraps bubbletea in a small
Spinnerinterface and wires it intonodeup upgrade's long-running steps (manifest fetch, per-version snapshot, per-version install, set-default, global-package restore). Plain-mode callers get a one-shot "label ..." line per operation so CI / piped-output stays log-friendly; Fancy-mode callers get an animated braille-pattern spinner driven by a realtea.Program.This is PR2 of 3 for #105 (PR1 was the scaffold via #74; PR3 will add
huhprompts +detector.ResolveInteractive; PR4 will migrate the remaining call sites —check,list,packages,config— toui.Writer).Why a separate PR
The Charm-stack migration is large enough that mixing it into one PR would balloon review surface and confuse the commitlint history (PR4 is mostly
cmd.Printf→ui.Out(w)mechanical substitutions; PR3 introduces interactive prompts). Splitting along the existing #74 phasing keeps each PR reviewable in ~10 minutes.Implementation
internal/ui/spinner.goSpinnerinterface:Start() (stop func())andMode() Mode.NewSpinner(mode, label, out): routes tofancySpinner(FancyMode + non-nil writer) orplainSpinner(everything else).plainSpinner: prints"label ..."onStart().Stop()is a no-op so the caller can emit the success/failure line viaWriter.Success/Writer.Errorwithout producing a second spinner record.fancySpinner: pipes atea.Programthrough a transient stderr handle so spinner frames don't interleave withWriter.Successlines emitted during the same operation. Standard braille-pattern frame set, 80ms tick.Stop()callsp.Quit(), waits ≤200ms for teardown, closes the pipe.WaitWithSpinner(ctx, s, work): the production helper. Returnsnilon success,work's error if work errors, orctx.Err()if ctx fires first. Does not preempt work — callers who need preemptible work must thread ctx into work themselves.internal/ui/spinner_test.go(new)TestNewSpinner_PlainModeHonored— plain prints label, Stop is no-op/idempotent.TestNewSpinner_FancyModeSelected— fancy mode decision + lifecycle safe under non-TTYgo test.TestNewSpinner_FancyNilWriterDegradesToPlain—(FancyMode, nil)doesn't NPE inside tea.TestWaitWithSpinner_NilSpinnerPassesThrough— useful for tests that don't want a spinner.TestWaitWithSpinner_HappyPath— work nil → helper nil.TestWaitWithSpinner_WorkErrorReturned— work error surfaces.TestWaitWithSpinner_ContextCancelReturns— ctx cancel returnscontext.Canceled, work goroutine is not preempted (left parked, released by test's defer).internal/cli/upgrade.gospinnerFor(cmd, label)constructs the spinner matching the activeui.Writer.Mode(), rendered tocmd.ErrOrStderr(). Stderr is deliberate: plain-mode users can pipenodeup upgrade | jqand the human-side channel still has the spinner line.ui.WaitWithSpinner:node.FetchManifestCtx, per-versionpackages.Snapshot, per-versionm.Install,m.SetDefault,packages.RestoreFromSnapshot.go.mod/go.sumgithub.com/charmbracelet/bubbletea v1.3.10. New transitive deps:charmbracelet/x/ansi v0.10.1,erikgeiser/coninput,mattn/go-localereader,muesli/ansi,muesli/cancelreader,golang.org/x/text v0.3.8,golang.org/x/sys v0.36.0. (Thex/ansibump from 0.8.0 → 0.10.1 is bubbletea's requirement; safe per upstream changelog.)Edge cases I considered before opening
go teststdin/stdout are pipes. Tests pin the decision point (FancyMode → spinner.Mode()==FancyMode) and the lifecycle (Start/Stop don't panic/deadlock) but don't assert on rendered frame bytes. End-to-end "does the spinner look right" is a manual smoke test, documented in PR3's body when huh lands.tea.NewProgram(nil-writer)would NPE deep in bubbletea. We degrade to no-op rather than crashing mid-upgrade.spinner_test.gopins this.bufio.NewReader(cmd.InOrStdin()). In FancyMode the spinner was rendering to stderr; stdin stays free for the prompt. Verified by reading the existingrunCleanupPromptcodepath and confirming it never touches stderr.p.Quit()then waits; a second call wouldp.Quit()again (also idempotent in bubbletea), close already-closed pipes (errors swallowed), and return. No goroutine leak in either case."label ..."line is the only spinner-side record; the success/failure line comes from the caller'sWriter.Success/Writer.Error. Tests pin thatStop()writes no extra bytes in plain mode.tea.WithoutSignalHandler: without this, bubbletea would install its own SIGINT handler that races with ourcmd.Context()cancellation. We install our own ctx-aware path and passWithoutSignalHandlerso bubbletea leaves signal handling alone.tea.WithoutCatchPanics: bubbletea wraps every Update call in a recover-by-default. We opt out so a panic in the spinner model surfaces as a real panic (test runner / Go runtime catches it) instead of being silently swallowed and the program just stopping — which would manifest as a hung upgrade.Verification
Plus a manual
make build && ./bin/nodeup upgrade --helpto confirm the binary still launches cleanly.Out of scope (deferred to PR3 / PR4 per #105 phasing)
huh-backed prompts forcleanupand the manager-resolution decision (PR3 — also addsdetector.ResolveInteractive).cmd.Printfincheck,list,packages,config, and the remainingupgradelines (PR4). The plain-mode spinner line emitted byui.NewSpinnerhere is alreadyui-routed, so PR4 just has to swap thecmd.Printfaround the spinners, not inside them.Part of #105. PR3 (huh prompts + ResolveInteractive) and PR4 (full call-site migration) will follow in this same issue. This PR alone does not close #105.