Skip to content

feat(ui): add bubbletea-backed spinner for long-running steps#117

Merged
dipto0321 merged 1 commit into
mainfrom
feat/ui/spinner-via-bubbletea
Jul 5, 2026
Merged

feat(ui): add bubbletea-backed spinner for long-running steps#117
dipto0321 merged 1 commit into
mainfrom
feat/ui/spinner-via-bubbletea

Conversation

@dipto0321

@dipto0321 dipto0321 commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Summary

Wraps bubbletea in a small Spinner interface and wires it into nodeup 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 real tea.Program.

This is PR2 of 3 for #105 (PR1 was the scaffold via #74; PR3 will add huh prompts + detector.ResolveInteractive; PR4 will migrate the remaining call sites — check, list, packages, config — to ui.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.Printfui.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.go

  • Spinner interface: Start() (stop func()) and Mode() Mode.
  • NewSpinner(mode, label, out): routes to fancySpinner (FancyMode + non-nil writer) or plainSpinner (everything else).
  • plainSpinner: prints "label ..." on Start(). Stop() is a no-op so the caller can emit the success/failure line via Writer.Success / Writer.Error without producing a second spinner record.
  • fancySpinner: pipes a tea.Program through a transient stderr handle so spinner frames don't interleave with Writer.Success lines emitted during the same operation. Standard braille-pattern frame set, 80ms tick. Stop() calls p.Quit(), waits ≤200ms for teardown, closes the pipe.
  • WaitWithSpinner(ctx, s, work): the production helper. Returns nil on success, work's error if work errors, or ctx.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-TTY go 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 returns context.Canceled, work goroutine is not preempted (left parked, released by test's defer).

internal/cli/upgrade.go

  • spinnerFor(cmd, label) constructs the spinner matching the active ui.Writer.Mode(), rendered to cmd.ErrOrStderr(). Stderr is deliberate: plain-mode users can pipe nodeup upgrade | jq and the human-side channel still has the spinner line.
  • Five call sites wrapped in ui.WaitWithSpinner: node.FetchManifestCtx, per-version packages.Snapshot, per-version m.Install, m.SetDefault, packages.RestoreFromSnapshot.

go.mod / go.sum

  • Adds github.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. (The x/ansi bump from 0.8.0 → 0.10.1 is bubbletea's requirement; safe per upstream changelog.)

Edge cases I considered before opening

  • TTY-faking in unit tests: bubbletea's renderer needs a real terminal to produce frames. Under go test stdin/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.
  • Nil writer under FancyMode: tea.NewProgram(nil-writer) would NPE deep in bubbletea. We degrade to no-op rather than crashing mid-upgrade. spinner_test.go pins this.
  • Double-prompt cleanup interaction: the cleanup-prompt goroutine still uses bufio.NewReader(cmd.InOrStdin()). In FancyMode the spinner was rendering to stderr; stdin stays free for the prompt. Verified by reading the existing runCleanupPrompt codepath and confirming it never touches stderr.
  • Stop() called twice: plain spinner's Stop is a no-op (idempotent). Fancy spinner's Stop calls p.Quit() then waits; a second call would p.Quit() again (also idempotent in bubbletea), close already-closed pipes (errors swallowed), and return. No goroutine leak in either case.
  • Plain-mode log count: per the project's V2 invariant, one log record per operation. Plain spinner's "label ..." line is the only spinner-side record; the success/failure line comes from the caller's Writer.Success / Writer.Error. Tests pin that Stop() writes no extra bytes in plain mode.
  • tea.WithoutSignalHandler: without this, bubbletea would install its own SIGINT handler that races with our cmd.Context() cancellation. We install our own ctx-aware path and pass WithoutSignalHandler so 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

$ make ci
go mod tidy
gofmt -s -w .
go vet ./...
golangci-lint run ./...
0 issues.
go test -race -coverprofile=coverage.out -covermode=atomic ./...
ok  	github.com/dipto0321/nodeup/internal/cli	2.345s	coverage: 37.3%
ok  	github.com/dipto0321/nodeup/internal/ui	1.776s	coverage: 73.1%
...
total: 62.4%

Plus a manual make build && ./bin/nodeup upgrade --help to confirm the binary still launches cleanly.

Out of scope (deferred to PR3 / PR4 per #105 phasing)

  • huh-backed prompts for cleanup and the manager-resolution decision (PR3 — also adds detector.ResolveInteractive).
  • Migrating cmd.Printf in check, list, packages, config, and the remaining upgrade lines (PR4). The plain-mode spinner line emitted by ui.NewSpinner here is already ui-routed, so PR4 just has to swap the cmd.Printf around 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.

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>
@cocogitto-bot

cocogitto-bot Bot commented Jul 5, 2026

Copy link
Copy Markdown

✔️ 19ed51e - Conventional commits check succeeded.

@dipto0321 dipto0321 merged commit 92ed231 into main Jul 5, 2026
10 checks passed
@dipto0321 dipto0321 deleted the feat/ui/spinner-via-bubbletea branch July 5, 2026 07:40
dipto0321 added a commit that referenced this pull request Jul 5, 2026
Closes #121. body-max-line-length raised to 200 to match footer; retroactive fix for the four historical lint failures (#117/#118/#119/#120) caused by admin squash-merge prose over 100 cols.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(ui): complete internal/ui rollout — spinners, huh prompts, migrate remaining call sites (#74 phases 2–4)

1 participant