diff --git a/CLAUDE.md b/CLAUDE.md index bdf9a64..be1f1a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,11 @@ OpenBoot is a **macOS-only** Go 1.25 CLI that automates dev-environment setup: Homebrew packages/casks, npm globals, Oh-My-Zsh, macOS `defaults`, and dotfiles. Built on **Cobra** (CLI) + **Charmbracelet** (bubbletea / lipgloss / huh for TUI). Entry point: `cmd/openboot/main.go` → `internal/cli.Execute()`. -Core flow: `openboot install` orchestrates plan → apply in `internal/installer/installer.go`. Bare interactive `openboot install` on a TTY runs the full-screen install TUI in `internal/ui/tui/wizard/` (boot probe → select → live install); `-p ` enters the same wizard with the loadout preselected, and slug/`-u`/`--from`/alias installs enter it in config mode (`RunForConfig`: the config's own packages on the select screen, preselected). Sync-source installs keep their linear diff pre-flight but stream the apply through the wizard's live install screen (`RunPipeline`). `--silent`, `--dry-run`, `--update`, and non-TTY runs use the linear flow. +Core flow: `openboot install` orchestrates plan → apply in `internal/installer/installer.go`. + +**The wizard plans; the apply is always linear.** On a TTY the planning phase runs as a full-screen TUI in `internal/ui/tui/wizard/` (boot probe → select → git → review), then the wizard *exits* and hands its `InstallPlan` to `installer.ApplyReviewedPlan`, which applies it on the normal terminal with `ConsoleReporter` + `ui.StickyProgress`. This split is deliberate: a TUI is right for browsing a 100+ package catalog, and wrong for the apply — an alt-screen install discards its own output when it exits, so twenty minutes of package results and failures vanish. Streamed into the scrollback they stay where the user can scroll back, copy an error, and pipe it. Don't move the apply back inside the alt-screen. + +Entry points: bare `install` → `wizard.Run`; `-p ` → same, loadout preselected; slug/`-u`/`--from`/alias → `wizard.RunForConfig` (config mode: the config's own packages on the select screen, preselected). Sync-source installs keep their linear diff pre-flight and apply linearly. `--silent`, `--dry-run`, `--update`, `--pick`, and non-TTY runs never enter the wizard. For full contribution guide (test layering L1–L4, Runner interface, hook setup) see @CONTRIBUTING.md. For AI agents: @AGENTS.md indexes invariants enforced by `internal/archtest`; @docs/HARNESS.md is the steering meta-doc for where to encode new rules. diff --git a/internal/cli/install.go b/internal/cli/install.go index f4f960c..e67afc7 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -14,7 +14,6 @@ import ( "github.com/openbootdotdev/openboot/internal/auth" "github.com/openbootdotdev/openboot/internal/config" "github.com/openbootdotdev/openboot/internal/installer" - "github.com/openbootdotdev/openboot/internal/progress" syncpkg "github.com/openbootdotdev/openboot/internal/sync" "github.com/openbootdotdev/openboot/internal/system" "github.com/openbootdotdev/openboot/internal/ui" @@ -113,7 +112,7 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / if src.kind == sourceSyncSource { pickRaw, _ := cmd.Flags().GetString("pick") - return runSyncInstall(src.syncSource, pickRaw) + return runSyncInstall(cmd.Context(), src.syncSource, pickRaw) } if err := applyInstallSource(src); err != nil { @@ -124,7 +123,7 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / // let it fall through to the existing "--pick requires a remote // config" error instead of silently dropping it. if pickRaw, _ := cmd.Flags().GetString("pick"); pickRaw == "" && shouldLaunchWizard(src) { - return runInstallWizard() + return runInstallWizard(cmd.Context()) } } @@ -141,7 +140,7 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / // (select the config's packages → review → live install) — // replacing the linear 3-way prompt + customizer that used to // precede the pipeline screen for slug / -u / --from / alias. - return runConfigWizard(installCfg.RemoteConfig) + return runConfigWizard(cmd.Context(), installCfg.RemoteConfig) } else if !installCfg.Silent && (!installCfg.DryRun || system.HasTTY()) { rc, proceed, err := promptCustomizeAndApply(installCfg.RemoteConfig) if err != nil { @@ -157,13 +156,6 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / return fmt.Errorf("--pick requires a remote config; use the preset selector instead") } - // On a TTY, stream the apply through the wizard's live pipeline so a slug / - // preset / RemoteConfig install shares the full-screen visuals instead of - // the linear "Step N" output. Non-interactive runs keep RunContext. - if !installCfg.Silent && !installCfg.DryRun && !installCfg.Update && system.HasTTY() { - return runPipelineInstall(cmd.Context()) - } - err := installer.RunContext(cmd.Context(), installCfg) if errors.Is(err, installer.ErrUserCancelled) { return nil @@ -201,112 +193,51 @@ func wizardSource(src *installSource) bool { } } -// runPipelineInstall resolves the plan (with linear pre-flight prep) and applies -// it through the wizard's live pipeline screen, so a slug/preset/RemoteConfig -// install streams like the wizard. ApplyContext is the same engine RunContext -// uses; only the reporter differs. Follow-ups that can't run inside the -// alt-screen (screen-recording reminder) happen after, on a normal terminal. -func runPipelineInstall(ctx context.Context) error { - plan, err := installer.PlanForConfig(installCfg) - if err != nil { - if errors.Is(err, installer.ErrUserCancelled) { - return nil - } - return err - } - // Silence keeps the in-apply reminder/prompts out of the alt-screen; it's - // re-run afterwards via ShowScreenRecordingReminderAfterTUI. - plan.Silent = true - - // Post-install needs a script preview + confirm, which the alt-screen can't - // host. Strip it from the streamed plan and run it after teardown on a normal - // terminal (Silent=true would otherwise gate it out entirely — the R1 bug). - streamed, deferredPostInstall := splitPostInstall(plan) - - runErr := wizard.RunPipeline(installCfg.Version, wizard.PhasesForPlan(streamed), - func(ctx context.Context, r installer.Reporter) error { - return installer.ApplyContext(ctx, streamed, r) - }) - if errors.Is(runErr, wizard.ErrAborted) || errors.Is(runErr, context.Canceled) { +// applyReviewedPlan applies a plan the wizard resolved, on the normal +// terminal. The alt-screen is torn down by the time we get here, which is the +// whole point: brew/npm results stream into the scrollback, so after a +// twenty-minute install the user can still scroll back, copy a failure, or +// pipe the output — none of which survived a full-screen install. +func applyReviewedPlan(ctx context.Context, plan installer.InstallPlan) error { + err := installer.ApplyReviewedPlan(ctx, plan) + if errors.Is(err, context.Canceled) { return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs") } - // The install ran (cleanly or with soft errors). On a clean run, run the - // deferred post-install script (Step 8, matching the linear order) before the - // reminder; a failing script is a soft error, not fatal. Then show the - // reminder and persist the sync source. - if runErr == nil && len(deferredPostInstall) > 0 { - plan.PostInstall = deferredPostInstall - if piErr := installer.RunPostInstallAfterTUI(plan); piErr != nil { - ui.Error(fmt.Sprintf("Post-install script failed: %v", piErr)) - } - } - installer.ShowScreenRecordingReminderAfterTUI(plan) - if runErr == nil { - saveSyncSourceIfRemote(installCfg) - } - return runErr -} - -// splitPostInstall separates a plan into the version streamed through the wizard -// pipeline (post-install removed — the alt-screen can't host its confirm) and -// the post-install script to run after teardown. Keeping it a pure function -// makes the split testable without a TTY. -func splitPostInstall(plan installer.InstallPlan) (streamed installer.InstallPlan, postInstall []string) { - postInstall = plan.PostInstall - plan.PostInstall = nil - return plan, postInstall + return err } -// runInstallWizard launches the full-screen install TUI and runs the resulting -// install. The wizard owns the whole interactive flow (planning + apply); back -// on the normal terminal, follow-ups that can't run inside the alt-screen -// (screen-recording reminder) happen here. -func runInstallWizard() error { - opts := installCfg.ToInstallOptions() - plan, confirmed, err := wizard.Run(installCfg.Version, opts) - // Show the screen-recording reminder for any install that actually ran — - // including one that ended in a soft error (e.g. a cask installed, then - // dotfiles failed), matching the linear installer. Skip it only on a user - // abort, where nagging would be noise. - if confirmed && !errors.Is(err, wizard.ErrAborted) { - installer.ShowScreenRecordingReminderAfterTUI(plan) - } +// runInstallWizard runs the planning wizard for a bare or preset install, then +// applies what the user reviewed. +func runInstallWizard(ctx context.Context) error { + plan, confirmed, err := wizard.Run(installCfg.Version, installCfg.ToInstallOptions()) if err != nil { - if errors.Is(err, wizard.ErrAborted) { - return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs") - } return fmt.Errorf("install wizard: %w", err) } - return nil + if !confirmed { + ui.Info("Cancelled.") + return nil + } + return applyReviewedPlan(ctx, plan) } -// runConfigWizard launches the full-screen wizard in config mode for a fetched -// remote config. Follow-ups the alt-screen can't host — the post-install -// script (needs a preview + confirm) and the screen-recording reminder — run -// here afterwards, on a normal terminal, mirroring runPipelineInstall's order. -func runConfigWizard(rc *config.RemoteConfig) error { - opts := installCfg.ToInstallOptions() - plan, confirmed, err := wizard.RunForConfig(installCfg.Version, opts, rc) - if errors.Is(err, wizard.ErrAborted) || errors.Is(err, context.Canceled) { - return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs") +// runConfigWizard runs the planning wizard in config mode for a fetched remote +// config, then applies what the user reviewed. The plan carries the config's +// post-install script through to applyPostInstall's own preview + confirm, +// which now works because we're on a normal terminal. +func runConfigWizard(ctx context.Context, rc *config.RemoteConfig) error { + plan, confirmed, err := wizard.RunForConfig(installCfg.Version, installCfg.ToInstallOptions(), rc) + if err != nil { + return fmt.Errorf("install wizard: %w", err) } if !confirmed { - if err != nil { - return fmt.Errorf("install wizard: %w", err) - } ui.Info("Cancelled.") return nil } - if err == nil && len(plan.PostInstall) > 0 { - if piErr := installer.RunPostInstallAfterTUI(plan); piErr != nil { - ui.Error(fmt.Sprintf("Post-install script failed: %v", piErr)) - } - } - installer.ShowScreenRecordingReminderAfterTUI(plan) - if err == nil { - saveSyncSourceIfRemote(installCfg) + if err := applyReviewedPlan(ctx, plan); err != nil { + return err } - return err + saveSyncSourceIfRemote(installCfg) + return nil } // ── Source resolution ───────────────────────────────────────────────────────── @@ -439,7 +370,7 @@ func applyInstallSource(src *installSource) error { // runSyncInstall is the flow when `openboot install` is called without args // and a sync source exists. It fetches the remote config, shows a diff, and // applies only the additions (install is add-only). -func runSyncInstall(source *syncpkg.SyncSource, pickRaw string) error { //nolint:gocyclo // orchestrates --pick filter, dry-run, 3-way prompt, and customize TUI for the sync-source path; splitting would scatter the flow +func runSyncInstall(ctx context.Context, source *syncpkg.SyncSource, pickRaw string) error { //nolint:gocyclo // orchestrates --pick filter, dry-run, 3-way prompt, and customize TUI for the sync-source path; splitting would scatter the flow printSyncSourceHeader(source) var token string @@ -528,30 +459,10 @@ func runSyncInstall(source *syncpkg.SyncSource, pickRaw string) error { //nolint plan := buildInstallPlan(diff, rc) - // On a TTY, apply through the wizard's live pipeline screen so the sync - // install shares the full-screen streaming visuals. The install runs on - // RunPipeline's goroutine, so we must NOT read syncpkg.Execute's *result - // here (that would race the goroutine); we rely only on the returned error, - // which is delivered to us safely through the model. Execute's joined - // step-errors surface as that error, so a config-step failure isn't hidden. - if !installCfg.Silent && system.HasTTY() { - execErr := wizard.RunPipeline(installCfg.Version, syncPipelinePhases(plan), - func(ctx context.Context, _ installer.Reporter) error { - _, err := syncpkg.ExecuteContext(ctx, plan, false) - return err - }) - if errors.Is(execErr, wizard.ErrAborted) || errors.Is(execErr, context.Canceled) { - return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs") - } - if execErr == nil { - updateSyncedAt(source, "", rc) - } - return execErr // joined step-errors, printed by cobra - } - - // No TTY (scripts, piped output): linear synchronous apply + printed summary. + // Sync applies linearly on every path: the results belong in the scrollback, + // not in an alt-screen that discards them when it exits. ui.Println() - result, execErr := syncpkg.Execute(plan, false) + result, execErr := syncpkg.ExecuteContext(ctx, plan, false) ui.Println() if result.Installed > 0 { ui.Success(fmt.Sprintf("Installed %d package(s)", result.Installed)) @@ -562,38 +473,15 @@ func runSyncInstall(source *syncpkg.SyncSource, pickRaw string) error { //nolint for _, e := range result.Errors { ui.Error(fmt.Sprintf("Failed: %s", e)) } + if errors.Is(execErr, context.Canceled) { + return fmt.Errorf("installation aborted — partially applied changes are logged in ~/.openboot/logs") + } if execErr == nil || result.Installed > 0 || result.Updated > 0 { updateSyncedAt(source, "", rc) } return execErr } -// syncPipelinePhases derives the wizard pipeline sidebar from a sync plan. -// Taps run but aren't shown (they emit no per-item progress); config steps -// (dotfiles/shell/macOS) show as single rows that complete when Execute returns. -func syncPipelinePhases(p *syncpkg.SyncPlan) []wizard.PipelinePhase { - var ps []wizard.PipelinePhase - if n := len(p.InstallFormulae); n > 0 { - ps = append(ps, wizard.PipelinePhase{Name: progress.PhaseHomebrew, Total: n, Pkg: true}) - } - if n := len(p.InstallCasks); n > 0 { - ps = append(ps, wizard.PipelinePhase{Name: progress.PhaseApplications, Total: n, Pkg: true}) - } - if n := len(p.InstallNpm); n > 0 { - ps = append(ps, wizard.PipelinePhase{Name: progress.PhaseNpm, Total: n, Pkg: true}) - } - if p.UpdateDotfiles != "" { - ps = append(ps, wizard.PipelinePhase{Name: "Dotfiles", Total: 1}) - } - if p.UpdateShell { - ps = append(ps, wizard.PipelinePhase{Name: "Shell", Total: 1}) - } - if len(p.UpdateMacOSPrefs) > 0 { - ps = append(ps, wizard.PipelinePhase{Name: "macOS prefs", Total: 1}) - } - return ps -} - // printSyncSourceHeader shows the "→ Syncing with X (last synced Y)" line at // the top of a sync-source install. Warns in yellow if > 90 days stale. func printSyncSourceHeader(source *syncpkg.SyncSource) { diff --git a/internal/cli/post_install_test.go b/internal/cli/post_install_test.go deleted file mode 100644 index bd527f2..0000000 --- a/internal/cli/post_install_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package cli - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/openbootdotdev/openboot/internal/installer" -) - -// splitPostInstall must remove the post-install script from the plan streamed -// through the wizard pipeline (the alt-screen can't host its confirm) while -// preserving it for the after-teardown run. This is the R1 regression: the -// pipeline path forced plan.Silent=true, which silently gated out post-install -// execution that the linear path used to preview, confirm, and run. -func TestSplitPostInstall(t *testing.T) { - plan := installer.InstallPlan{ - Formulae: []string{"jq"}, - PostInstall: []string{"echo hi", "echo bye"}, - } - - streamed, deferred := splitPostInstall(plan) - - assert.Nil(t, streamed.PostInstall, "streamed plan must not carry post-install") - assert.Equal(t, []string{"echo hi", "echo bye"}, deferred, "post-install deferred to after teardown") - assert.Equal(t, []string{"jq"}, streamed.Formulae, "the rest of the plan is untouched") - assert.Equal(t, []string{"echo hi", "echo bye"}, plan.PostInstall, "the caller's plan is not mutated") -} - -func TestSplitPostInstall_Empty(t *testing.T) { - streamed, deferred := splitPostInstall(installer.InstallPlan{Formulae: []string{"jq"}}) - assert.Empty(t, deferred) - assert.Equal(t, []string{"jq"}, streamed.Formulae) -} diff --git a/internal/cli/sync_helpers_test.go b/internal/cli/sync_helpers_test.go index fc7f0bf..f7f1fcf 100644 --- a/internal/cli/sync_helpers_test.go +++ b/internal/cli/sync_helpers_test.go @@ -277,31 +277,3 @@ func TestUpdateSyncedAt_ZeroInstalledAt_UseNow(t *testing.T) { } // ── syncPipelinePhases ──────────────────────────────────────────────────────── - -func TestSyncPipelinePhases(t *testing.T) { - plan := &syncpkg.SyncPlan{ - InstallFormulae: []string{"jq", "ripgrep"}, - InstallCasks: []string{"orbstack"}, - InstallTaps: []string{"some/tap"}, // runs, but not shown as a phase - UpdateDotfiles: "https://github.com/x/dotfiles", - UpdateMacOSPrefs: []config.RemoteMacOSPref{{Key: "a"}, {Key: "b"}}, - // no npm, no shell - } - ps := syncPipelinePhases(plan) - - require.Len(t, ps, 4, "Homebrew, Applications, Dotfiles, macOS prefs — taps and npm/shell excluded") - assert.Equal(t, "Homebrew", ps[0].Name) - assert.Equal(t, 2, ps[0].Total) - assert.True(t, ps[0].Pkg) - assert.Equal(t, "Applications", ps[1].Name) - assert.True(t, ps[1].Pkg) - assert.Equal(t, "Dotfiles", ps[2].Name) - assert.Equal(t, 1, ps[2].Total) - assert.False(t, ps[2].Pkg, "config steps are not per-item package phases") - assert.Equal(t, "macOS prefs", ps[3].Name) - assert.Equal(t, 1, ps[3].Total) -} - -func TestSyncPipelinePhasesEmpty(t *testing.T) { - assert.Empty(t, syncPipelinePhases(&syncpkg.SyncPlan{}), "an empty plan yields no phases") -} diff --git a/internal/installer/installer.go b/internal/installer/installer.go index b7e2519..ab74765 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -84,31 +84,27 @@ func runInstallContext(ctx context.Context, opts *config.InstallOptions, st *con return ApplyContext(ctx, plan, ConsoleReporter{}) } -// PlanForConfig runs the pre-flight dependency check and resolves the install -// plan for cfg — the linear RunContext flow up to (but not including) Apply. It -// exists so the wizard pipeline can do the interactive prep on a normal -// terminal, then stream the apply itself via ApplyContext with its own Reporter. -func PlanForConfig(cfg *config.Config) (InstallPlan, error) { - opts := cfg.ToInstallOptions() - st := cfg.ToInstallState() - // Mirror runInstallContext's install_started event: the pipeline path skips - // runInstallContext, so without this a streamed install logs install_completed - // (from ApplyContext) with no matching install_started. +// ApplyReviewedPlan applies a plan the install wizard already resolved and +// the user already reviewed. It is runInstallContext's tail — banner, apply, +// completion — for the case where planning happened in the TUI instead. +// +// It runs on the normal terminal, by design: the wizard exits before this is +// called, so package results scroll into the user's scrollback and the +// completion summary stays on screen afterwards. Applying inside the +// alt-screen instead meant the whole run vanished on exit, and plan.Silent had +// to be forced on to keep prompts (npm retry, screen-recording) from painting +// over the TUI. Here the plan's own Silent value stands and those prompts work +// where they belong. +func ApplyReviewedPlan(ctx context.Context, plan InstallPlan) error { slog.Info("install_started", - "version", opts.Version, - "preset", opts.Preset, - "user", opts.User, - "dry_run", opts.DryRun, - "silent", opts.Silent, + "version", plan.Version, + "dry_run", plan.DryRun, + "silent", plan.Silent, ) - if err := checkDependencies(opts, st); err != nil { - return InstallPlan{}, fmt.Errorf("check dependencies: %w", err) - } - plan, err := Plan(opts, st) - if err != nil { - return InstallPlan{}, fmt.Errorf("plan install: %w", err) - } - return plan, nil + ui.Println() + ui.Header(fmt.Sprintf("OpenBoot Installer v%s", plan.Version)) + ui.Println() + return ApplyContext(ctx, plan, ConsoleReporter{}) } // Apply executes a resolved InstallPlan, reporting progress via r. @@ -216,35 +212,6 @@ func showCompletionFromPlan(plan InstallPlan, r Reporter, errCount int) { ui.Println() } -// ShowScreenRecordingReminderAfterTUI re-runs the screen-recording permission -// reminder for a plan applied by the full-screen wizard. The wizard forces -// plan.Silent=true to keep prompts out of the alt-screen, which also -// suppresses this reminder; the CLI calls this after the TUI exits, back on a -// normal terminal. -func ShowScreenRecordingReminderAfterTUI(plan InstallPlan) { - plan.Silent = false - showScreenRecordingReminderFromPlan(plan) -} - -// RunPostInstallAfterTUI runs the plan's post-install script on a normal -// terminal after the full-screen wizard has torn down. The wizard forces -// plan.Silent=true to keep prompts out of the alt-screen — but that also gates -// out post-install *execution* (applyPostInstall skips when Silent), so a -// slug/RemoteConfig install streamed through the pipeline would silently drop a -// script the linear path would have previewed, confirmed, and run. The CLI -// defers it here with Silent cleared, so the script gets its preview + confirm -// and runs, matching the linear installer. No-op when there is no script. -func RunPostInstallAfterTUI(plan InstallPlan) error { - if len(plan.PostInstall) == 0 { - return nil - } - plan.Silent = false - if err := applyPostInstall(plan, ConsoleReporter{}); err != nil { - return fmt.Errorf("post-install: %w", err) - } - return nil -} - func showScreenRecordingReminderFromPlan(plan InstallPlan) { if plan.DryRun || plan.Silent { return diff --git a/internal/installer/installer_extra_test.go b/internal/installer/installer_extra_test.go index 78fb097..14213c5 100644 --- a/internal/installer/installer_extra_test.go +++ b/internal/installer/installer_extra_test.go @@ -20,13 +20,6 @@ func TestShowScreenRecordingReminderFromPlan_DryRun_NoOp(t *testing.T) { }) } -// RunPostInstallAfterTUI must be a no-op (no prompt, no error) when the plan has -// no post-install script — it's called on every pipeline install. The -// script-present path needs a TTY + real exec, so it's covered by e2e, not here. -func TestRunPostInstallAfterTUI_NoOpWhenEmpty(t *testing.T) { - assert.NoError(t, RunPostInstallAfterTUI(InstallPlan{})) -} - func TestShowScreenRecordingReminderFromPlan_Silent_NoOp(t *testing.T) { plan := InstallPlan{DryRun: false, Silent: true} assert.NotPanics(t, func() { diff --git a/internal/ui/progress.go b/internal/ui/progress.go index 8072bfe..139a63d 100644 --- a/internal/ui/progress.go +++ b/internal/ui/progress.go @@ -35,6 +35,7 @@ type StickyProgress struct { closeOnce sync.Once sigCh chan os.Signal active bool + aborting bool // first ctrl+c seen; a second one force-quits succeeded int failed int skipped int @@ -74,8 +75,9 @@ func (sp *StickyProgress) Start() { case <-sp.stopCh: return case <-sp.sigCh: - sp.Finish() - os.Exit(130) + if sp.onInterrupt() { + os.Exit(130) + } case <-ticker.C: sp.mu.Lock() sp.spinnerIdx = (sp.spinnerIdx + 1) % len(spinnerFrames) @@ -193,6 +195,38 @@ func (sp *StickyProgress) PrintLine(format string, args ...interface{}) { } } +// onInterrupt handles ctrl+c while the bar is running, and reports whether the +// caller should force-quit. +// +// The first interrupt only tears down the sticky rendering and restores the +// terminal. It deliberately does NOT exit: the same signal cancels the install +// context, which kills the running brew/npm subprocess and makes ApplyContext +// bail before any further step mutates the system — an abort that unwinds and +// reports what it managed to do. Exiting here instead (the old behaviour) cut +// that short from a goroutine, skipping every deferred cleanup on the way out. +// +// A second interrupt reports true, so a subprocess that ignores the +// cancellation can still be escaped. That escape matters: signal.NotifyContext +// has already fired by then and swallows further SIGINTs, so without this the +// terminal would look dead. +func (sp *StickyProgress) onInterrupt() (force bool) { + sp.mu.Lock() + defer sp.mu.Unlock() + if sp.aborting { + return true + } + sp.aborting = true + sp.active = false + if sp.region != nil { + sp.region.Stop() + sp.region = nil + } else { + fmt.Printf("\r\033[K") + } + fmt.Println(mutedStyle.Render(" aborting — waiting for the current step to stop · ctrl+c again to force quit")) + return false +} + func (sp *StickyProgress) Finish() { signal.Stop(sp.sigCh) sp.closeOnce.Do(func() { close(sp.stopCh) }) diff --git a/internal/ui/progress_test.go b/internal/ui/progress_test.go index 784b434..eeca6fb 100644 --- a/internal/ui/progress_test.go +++ b/internal/ui/progress_test.go @@ -170,3 +170,19 @@ func TestStickyProgressFallsBackWhenScrollRegionUnsupported(t *testing.T) { sp.mu.Unlock() assert.False(t, hasRegion, "scroll region should be disabled on dumb TERM") } + +// Ctrl+C during a linear install must not exit from the progress goroutine: +// the same signal cancels the install context, and ApplyContext gates every +// remaining step on it, so letting that unwind is what stops the run cleanly. +// Only a second interrupt is allowed to force the process out. +func TestStickyProgressInterruptIsTwoStage(t *testing.T) { + sp := NewStickyProgress(3) + + force := sp.onInterrupt() + assert.False(t, force, "first ctrl+c unwinds via the cancelled context, it does not exit") + assert.True(t, sp.aborting, "abort recorded") + assert.False(t, sp.active, "sticky rendering stopped so the abort notice isn't overpainted") + + force = sp.onInterrupt() + assert.True(t, force, "second ctrl+c escapes a subprocess that ignored the cancellation") +} diff --git a/internal/ui/tui/wizard/config_mode_test.go b/internal/ui/tui/wizard/config_mode_test.go index 9b6294d..329ec16 100644 --- a/internal/ui/tui/wizard/config_mode_test.go +++ b/internal/ui/tui/wizard/config_mode_test.go @@ -1,7 +1,6 @@ package wizard import ( - "strings" "testing" tea "github.com/charmbracelet/bubbletea" @@ -83,23 +82,22 @@ func TestConfigModeConfirmShowsPostInstallAndShiftsHitTest(t *testing.T) { assert.Equal(t, -1, miss, "the info line itself is not clickable") } -func TestConfigModeStartInstallDefersPostInstall(t *testing.T) { +// The wizard hands the plan back intact and installs nothing itself: the CLI +// applies it on the normal terminal, where post-install gets its own preview +// and confirm, and Silent stays as the run asked for rather than being forced +// on to keep prompts out of an alt-screen. +func TestConfigModeConfirmReturnsPlanWithoutInstalling(t *testing.T) { defer stubGitConfig("", "")() m := finishProbes(sizedConfig(testRemoteConfig())) m.installed = map[string]bool{} m = send(m, key("enter")) require.Equal(t, scrConfirm, m.screen) - m = send(m, key("enter")) // startInstall; the spawned engine cmd is dropped by send() + m = send(m, key("enter")) - require.Equal(t, scrInstall, m.screen) - require.True(t, m.confirmed) + require.True(t, m.confirmed, "↵ on the review screen accepts the plan") + require.True(t, m.quit, "and closes the wizard so the CLI can apply it") assert.Equal(t, []string{"echo hi"}, m.plan.PostInstall, - "returned plan keeps post-install for the CLI to run after teardown") - assert.True(t, m.plan.Silent) - var names []string - for _, p := range m.phases { - names = append(names, p.name) - } - assert.NotContains(t, strings.Join(names, " "), "post-install", - "no post-install phase streams inside the alt-screen") + "the config's post-install script reaches the CLI intact") + assert.False(t, m.plan.Silent, "an interactive run stays interactive") + assert.Contains(t, m.plan.Formulae, "cowsay") } diff --git a/internal/ui/tui/wizard/confirm.go b/internal/ui/tui/wizard/confirm.go index 3425599..c4d0d72 100644 --- a/internal/ui/tui/wizard/confirm.go +++ b/internal/ui/tui/wizard/confirm.go @@ -73,7 +73,7 @@ func (m Model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.quit = true return m, tea.Quit case "enter": - return m.startInstall() + return m.finish() } return m, nil } diff --git a/internal/ui/tui/wizard/frames_test.go b/internal/ui/tui/wizard/frames_test.go index 0ab5923..29adc34 100644 --- a/internal/ui/tui/wizard/frames_test.go +++ b/internal/ui/tui/wizard/frames_test.go @@ -8,7 +8,6 @@ import ( "github.com/openbootdotdev/openboot/internal/config" "github.com/openbootdotdev/openboot/internal/installer" "github.com/openbootdotdev/openboot/internal/macos" - "github.com/openbootdotdev/openboot/internal/progress" ) // send routes a message through Update and returns the resulting Model. @@ -102,34 +101,6 @@ func TestDumpFrames(t *testing.T) { c.screen = scrConfirm t.Log("\n===== CONFIRM (review) =====\n" + c.View()) - // Install: synthetic pipeline (no real Apply). - inst := installFrame(m, W, H) - t.Log("\n===== INSTALL (running) =====\n" + inst.View()) - - done := send(inst, installDoneMsg{}) - t.Log("\n===== INSTALL (done) =====\n" + done.View()) -} - -// installFrame sets up an install-screen model and feeds synthetic progress -// events without running the real install engine. -func installFrame(m Model, _, _ int) Model { - m.screen = scrInstall - m.installing = true - m.plan = installer.InstallPlan{ - Formulae: []string{"node", "go", "ripgrep", "fd", "bat"}, - Casks: []string{"visual-studio-code", "warp"}, - Npm: []string{"typescript", "eslint"}, - MacOSPrefs: make([]macos.Preference, 61), - } - m.plan.InstallOhMyZsh = true - m.plan.DotfilesURL = "https://github.com/x/dotfiles" - m.phases = buildPhases(m.plan) - m.installTick = m.ticks - - m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "node", Status: progress.StepStart, Command: "brew install node"}}) - m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "node", Status: progress.StepOK, Detail: "2.1s"}}) - m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "go", Status: progress.StepStart, Command: "brew install go"}}) - m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "go", Status: progress.StepOK, Detail: "3.4s"}}) - m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "ripgrep", Status: progress.StepStart, Command: "brew install ripgrep"}}) - return m + // There is no install frame: the wizard stops at the plan and the apply + // streams into the scrollback after the alt-screen is gone. } diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go deleted file mode 100644 index 23091de..0000000 --- a/internal/ui/tui/wizard/install.go +++ /dev/null @@ -1,643 +0,0 @@ -package wizard - -import ( - "context" - "errors" - "fmt" - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/openbootdotdev/openboot/internal/brew" - "github.com/openbootdotdev/openboot/internal/installer" - "github.com/openbootdotdev/openboot/internal/npm" - "github.com/openbootdotdev/openboot/internal/progress" -) - -// phaseState is one row of the install pipeline sidebar. -type phaseState struct { - name string - total int - done int - pkg bool // true for package phases (brew/cask/npm) that count per-item - active bool - finished bool -} - -// logLine is one rendered line of the live install log. -type logLine struct { - mark string - markColor lipgloss.Color - text string - color lipgloss.Color -} - -// reporterKind classifies an installer.Reporter call. -type reporterKind int - -const ( - rHeader reporterKind = iota - rInfo - rSuccess - rWarn - rError - rMuted -) - -type evMsg struct{ ev progress.Event } -type reporterMsg struct { - kind reporterKind - text string -} -type installDoneMsg struct{ err error } - -// chanReporter forwards installer.Reporter calls onto the wizard event channel. -type chanReporter struct{ ch chan tea.Msg } - -func (r chanReporter) Header(s string) { r.ch <- reporterMsg{kind: rHeader, text: s} } -func (r chanReporter) Info(s string) { r.ch <- reporterMsg{kind: rInfo, text: s} } -func (r chanReporter) Success(s string) { r.ch <- reporterMsg{kind: rSuccess, text: s} } -func (r chanReporter) Warn(s string) { r.ch <- reporterMsg{kind: rWarn, text: s} } -func (r chanReporter) Error(s string) { r.ch <- reporterMsg{kind: rError, text: s} } -func (r chanReporter) Muted(s string) { r.ch <- reporterMsg{kind: rMuted, text: s} } - -// ── starting the install ── - -// buildPlan resolves the current selection into an install plan — from the -// remote config in config mode, from the catalog selection otherwise. -func (m Model) buildPlan() installer.InstallPlan { - if m.rc != nil { - return installer.PlanForRemoteSelection(m.opts, m.rc, m.selected, m.selectedOnlinePkgs()) - } - return installer.PlanFromSelection(m.opts, m.selected, m.selectedOnlinePkgs()) -} - -func (m Model) startInstall() (tea.Model, tea.Cmd) { - plan := m.buildPlan() - // Apply a git identity captured on the git screen (fresh Mac). When git is - // already configured, these stay empty and PlanFromSelection's existing - // config is used instead. - if strings.TrimSpace(m.gitName) != "" && strings.TrimSpace(m.gitEmail) != "" { - plan.GitName = m.gitName - plan.GitEmail = m.gitEmail - plan.SkipGit = false - } - // Honor the confirm screen's toggles — a step switched off there must not - // run. - if !m.confShell { - plan.InstallOhMyZsh = false - plan.ShellTheme = "" - plan.ShellPlugins = nil - } - if !m.confDotfiles { - plan.DotfilesURL = "" - } - if !m.confPrefs { - plan.MacOSPrefs = nil - } - // Force non-interactive Apply: guarantees no huh prompt (git/npm-retry/ - // screen-recording reminder) fires mid-alt-screen. All decisions are - // already resolved in the plan. - plan.Silent = true - - m.plan = plan - // The alt-screen can't host the post-install script's confirm prompt; the - // CLI runs it after teardown from the returned plan (m.plan keeps it) — - // strip it from the streamed apply so ApplyContext doesn't execute it here. - streamed := plan - streamed.PostInstall = nil - m.phases = buildPhases(streamed) - m.logs = nil - m.skippedPkgs = 0 - m.aborting = false - m.screen = scrInstall - m.installing = true - m.done = false - m.confirmed = true - m.installTick = m.ticks - - ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is stored on the model and called on ctrl+c and install completion - m.cancel = cancel - - return m, tea.Batch(m.spawnInstall(ctx, streamed), waitForEvent(m.events)) -} - -// spawnInstall runs the install engine on a background goroutine, streaming -// progress onto the event channel. It returns immediately. -func (m Model) spawnInstall(ctx context.Context, plan installer.InstallPlan) tea.Cmd { - ch, done := m.events, m.installDone - return func() tea.Msg { - go func() { - sink := func(ev progress.Event) { ch <- evMsg{ev: ev} } - restoreBrew := brew.SetProgressSink(sink) - restoreNpm := npm.SetProgressSink(sink) - err := installer.ApplyContext(ctx, plan, chanReporter{ch: ch}) - restoreBrew() - restoreNpm() - // Signal that the engine has stopped touching os.Stdout (ApplyContext - // returned) BEFORE the channel send, so Run can restore stdout without - // racing us — even on a force-quit where nothing drains the channel and - // the send below could block on a full buffer. - close(done) - ch <- installDoneMsg{err: err} - }() - return nil - } -} - -// PipelinePhase describes one row of the install pipeline for RunPipeline. Use -// the progress.Phase* names for package phases so streamed events line up. -type PipelinePhase struct { - Name string - Total int - Pkg bool // true for per-item package phases (brew/cask/npm) -} - -func toPhaseStates(ps []PipelinePhase) []phaseState { - out := make([]phaseState, len(ps)) - for i, p := range ps { - out[i] = phaseState{name: p.Name, total: p.Total, pkg: p.Pkg} - } - return out -} - -// startPipeline runs an externally-supplied plan (RunPipeline / sync-source -// path) on a goroutine, wiring the same brew/npm sinks as spawnInstall so -// package progress streams into the shared install screen. -func (m Model) startPipeline() tea.Cmd { - ch, done, run, ctx := m.events, m.installDone, m.pipelineRun, m.pipelineCtx - return func() tea.Msg { - go func() { - sink := func(ev progress.Event) { ch <- evMsg{ev: ev} } - restoreBrew := brew.SetProgressSink(sink) - restoreNpm := npm.SetProgressSink(sink) - // chanReporter feeds section headers (→ phase activation) and outcome - // log lines onto the same channel, so an ApplyContext-based run - // streams exactly like the wizard's own install. - err := run(ctx, chanReporter{ch: ch}) - restoreBrew() - restoreNpm() - // See spawnInstall: close before the send so Run can join safely. - close(done) - ch <- installDoneMsg{err: err} - }() - return nil - } -} - -// PhasesForPlan derives pipeline phases from a resolved installer plan, so a -// RemoteConfig/preset install (install ) can drive RunPipeline with the -// same sidebar the wizard builds. -func PhasesForPlan(plan installer.InstallPlan) []PipelinePhase { - ps := buildPhases(plan) - out := make([]PipelinePhase, len(ps)) - for i, p := range ps { - out[i] = PipelinePhase{Name: p.name, Total: p.total, Pkg: p.pkg} - } - return out -} - -// buildPhases derives the pipeline sidebar from the plan. Package-phase totals -// count every planned package: the engine's streaming invariant is that each -// one produces exactly one terminal event (installed, failed, or -// already-installed skip), so totals and the event stream reconcile by -// construction — including alias-resolved and state-file skips. -func buildPhases(plan installer.InstallPlan) []phaseState { - var ps []phaseState - add := func(name string, total int, pkg, present bool) { - if present { - ps = append(ps, phaseState{name: name, total: total, pkg: pkg}) - } - } - add("Git identity", 1, false, !plan.PackagesOnly && !plan.SkipGit) - add(progress.PhaseHomebrew, len(plan.Formulae), true, len(plan.Formulae) > 0) - add(progress.PhaseApplications, len(plan.Casks), true, len(plan.Casks) > 0) - add(progress.PhaseNpm, len(plan.Npm), true, len(plan.Npm) > 0) - add("Shell", 1, false, !plan.PackagesOnly && plan.InstallOhMyZsh) - add("Dotfiles", 1, false, !plan.PackagesOnly && plan.DotfilesURL != "") - add("macOS prefs", 1, false, !plan.PackagesOnly && len(plan.MacOSPrefs) > 0) - return ps -} - -// pkgCount is the number of packages that will actually be installed, derived -// from the package-phase totals. -func (m Model) pkgCount() int { - n := 0 - for _, p := range m.phases { - if p.pkg { - n += p.total - } - } - return n -} - -// ── streaming event handling ── - -func (m Model) onInstallEvent(msg tea.Msg) (tea.Model, tea.Cmd) { - switch t := msg.(type) { - case evMsg: - m.applyProgressEvent(t.ev) - return m, waitForEvent(m.events) - - case reporterMsg: - if ph := headerPhase(t.text); ph != "" { - m.activatePhase(ph) - } - if line, ok := reporterLogLine(t); ok { - m.appendLog(line) - } - return m, waitForEvent(m.events) - - case installDoneMsg: - m.installing = false - m.done = true - m.installErr = t.err - if m.aborting { - // A ctrl+c cancel SIGKILLs the in-flight brew/npm subprocess, so t.err - // is usually non-nil ("signal: killed"). The old `&& t.err == nil` guard - // therefore left ErrAborted unset for the common case (abort during the - // package phase), and the CLI misreported the abort as an install - // failure. Join so errors.Is(err, ErrAborted) holds while the underlying - // cause stays in the chain for the log. - m.installErr = errors.Join(ErrAborted, t.err) - } - if m.cancel != nil { - m.cancel() - } - // Only a clean run gets every phase check-marked; on error or abort, - // phases that never completed stay visibly unfinished. - for i := range m.phases { - m.phases[i].active = false - if m.installErr == nil { - m.phases[i].finished = true - } - } - if m.aborting { - // The user asked to leave; the engine has now stopped — quit and - // let the CLI report the abort on a normal terminal. - m.quit = true - return m, tea.Quit - } - m.appendSummary() - return m, nil - } - return m, nil -} - -// appendSummary writes the run's outcome into the log tail — the counts, and -// crucially the names of any failed packages, which would otherwise have -// scrolled out of the visible log by the time the user reads the DONE screen. -func (m *Model) appendSummary() { - installed := m.pkgCount() - m.skippedPkgs - len(m.failedPkgs) - if installed < 0 { - installed = 0 - } - text := fmt.Sprintf("%d installed · %d already present · %s", - installed, m.skippedPkgs, fmtElapsed(m.elapsed())) - m.appendLog(logLine{}) // spacer - if len(m.failedPkgs) == 0 && m.installErr == nil { - m.appendLog(logLine{mark: "✓", markColor: cAccent, text: text, color: cTextHi}) - return - } - m.appendLog(logLine{mark: "!", markColor: cWarn, text: text, color: cTextHi}) - if len(m.failedPkgs) > 0 { - m.appendLog(logLine{mark: "✗", markColor: cDanger, - text: fmt.Sprintf("%d failed: %s", len(m.failedPkgs), strings.Join(m.failedPkgs, ", ")), color: cDanger}) - } -} - -func (m *Model) applyProgressEvent(ev progress.Event) { - m.activatePhase(ev.Phase) - switch ev.Status { - case progress.StepStart: - if ev.Command != "" { - m.appendLog(logLine{mark: "$", markColor: cDim4, text: ev.Command, color: cDim}) - } - if ev.Name != "" { - m.curStep = ev.Name - } - case progress.StepOK: - // The npm outer retry (applyNpm) re-runs the install and re-emits an - // "already installed" skip for every package a prior attempt installed — - // breaking the one-terminal-event-per-package invariant. Ignore a skip for - // a package that already had a terminal event, so skippedPkgs (which the - // completion footer subtracts from the package total) can't be inflated - // past the total and render a negative "N packages". - if ev.Detail == progress.SkipDetail && m.terminalSeen[termKey(ev)] { - return - } - m.markTerminal(ev) - m.incPhase(ev.Phase) - text := ev.Name - if ev.Detail != "" { - text += " — " + ev.Detail - } - if ev.Detail == progress.SkipDetail { - m.skippedPkgs++ - m.appendLog(logLine{mark: "○", markColor: cDim3, text: text, color: cDim}) - } else { - m.appendLog(logLine{mark: "✓", markColor: cAccent, text: text, color: cMuted}) - } - case progress.StepFail: - m.markTerminal(ev) - m.incPhase(ev.Phase) - if ev.Name != "" { - m.failedPkgs = append(m.failedPkgs, ev.Name) - } - m.appendLog(logLine{mark: "✗", markColor: cDanger, text: ev.Name + " (" + ev.Detail + ")", color: cDanger}) - } -} - -// termKey identifies a package's terminal event by phase + name. Empty for -// unnamed events (e.g. the batch StepStart), which are never deduped. -func termKey(ev progress.Event) string { - if ev.Name == "" { - return "" - } - return ev.Phase + "/" + ev.Name -} - -// markTerminal records that a package produced a terminal event, so a later -// re-emitted skip for it (npm retry) can be recognised as a duplicate. -func (m *Model) markTerminal(ev progress.Event) { - if k := termKey(ev); k != "" { - m.terminalSeen[k] = true - } -} - -// activatePhase marks phase name active and everything before it finished. -func (m *Model) activatePhase(name string) { - if name == "" { - return - } - idx := -1 - for i := range m.phases { - if m.phases[i].name == name { - idx = i - break - } - } - if idx < 0 { - return - } - for i := 0; i < idx; i++ { - if !m.phases[i].finished { - m.phases[i].active = false - m.phases[i].finished = true - } - } - if !m.phases[idx].finished { - m.phases[idx].active = true - } -} - -func (m *Model) incPhase(name string) { - for i := range m.phases { - if m.phases[i].name == name { - // Clamp: a retry pass emits a second terminal event for the same - // package; don't let done overrun the total. - if m.phases[i].done < m.phases[i].total { - m.phases[i].done++ - } - if m.phases[i].pkg && m.phases[i].done >= m.phases[i].total { - m.phases[i].active = false - m.phases[i].finished = true - } - return - } - } -} - -func (m *Model) appendLog(l logLine) { - m.logs = append(m.logs, l) - if len(m.logs) > 500 { - m.logs = m.logs[len(m.logs)-500:] - } -} - -// headerPhase maps an installer Header string onto a pipeline phase name. -func headerPhase(text string) string { - switch { - case strings.Contains(text, "Git Config"): - return "Git identity" - case strings.Contains(text, "Shell Config"): - return "Shell" - case strings.Contains(text, "Dotfiles"): - return "Dotfiles" - case strings.Contains(text, "macOS Prefer"): - return "macOS prefs" - case strings.Contains(text, "Installation"): - return progress.PhaseHomebrew - case strings.Contains(text, "NPM"): - return progress.PhaseNpm - } - return "" -} - -// reporterLogLine turns a meaningful reporter call (outcomes only) into a log -// line. Headers/info/muted are dropped to keep the log focused, matching the -// design's $cmd / ✓result cadence. -func reporterLogLine(t reporterMsg) (logLine, bool) { - text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(t.text), "✓")) - switch t.kind { - case rSuccess: - return logLine{mark: "✓", markColor: cAccent, text: text, color: cMuted}, true - case rWarn: - return logLine{mark: "!", markColor: cWarn, text: text, color: cWarn}, true - case rError: - return logLine{mark: "✗", markColor: cDanger, text: text, color: cDanger}, true - case rHeader, rInfo, rMuted: - // Dropped intentionally — headers drive phase activation (see - // headerPhase) and info/muted narration would drown the log. - return logLine{}, false - } - return logLine{}, false -} - -// ── counters used by the status bar ── - -func (m Model) totalSteps() int { - n := 0 - for _, p := range m.phases { - n += p.total - } - return n -} - -func (m Model) completedSteps() int { - n := 0 - for _, p := range m.phases { - if p.pkg { - n += min(p.done, p.total) - } else if p.finished { - n += p.total - } - } - return n -} - -func (m Model) elapsed() int { - return (m.ticks - m.installTick) * int(tickInterval.Milliseconds()) / 1000 -} - -// fmtElapsed renders whole seconds as "42s" or "3m24s" — a raw "7204s" after a -// long cask install reads as noise. -func fmtElapsed(secs int) string { - if secs < 60 { - return fmt.Sprintf("%ds", secs) - } - return fmt.Sprintf("%dm%ds", secs/60, secs%60) -} - -// ── key handling ── - -func (m Model) updateInstall(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - if m.done { - switch msg.String() { - case "r": - if m.pipelineRun == nil { // replay is a wizard-only action - return m.replay() - } - case "q", "enter", "esc": - return m, tea.Quit - } - } - return m, nil -} - -// updateInstallMouse handles mouse events on the install screen. -// During install: no action (user is watching progress). -// DONE screen: a click anywhere quits (same as enter/esc/q). -func (m Model) updateInstallMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { - if m.done && msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress { - return m, tea.Quit - } - return m, nil -} - -func (m Model) replay() (tea.Model, tea.Cmd) { - nm := New(m.version, m.opts) - nm.width, nm.height = m.width, m.height - nm.ticks = m.ticks - return nm, nm.runProbe(0) -} - -// ── rendering ── - -const pipelineW = 22 - -func (m Model) installBody(w, h int) string { - // Reserve the bottom rows for the progress bar / completion summary. - footer := m.installFooter(w) - footerH := len(footer) - paneH := h - footerH - 1 - if paneH < 1 { - paneH = 1 - } - - sidebar := m.pipelineSidebar(paneH) - logPane := m.logView(w-pipelineW-1, paneH) - - var lines []string - for i := 0; i < paneH; i++ { - l, r := "", "" - if i < len(sidebar) { - l = sidebar[i] - } - if i < len(logPane) { - r = logPane[i] - } - lines = append(lines, padTo(l, pipelineW)+fg(cBorder).Render("│")+r) - } - lines = append(lines, fg(cBorder).Render(strings.Repeat("─", w))) - lines = append(lines, footer...) - return strings.Join(lines, "\n") -} - -func (m Model) pipelineSidebar(h int) []string { - rows := make([]string, 0, h) - rows = append(rows, "") - rows = append(rows, " "+fg(cDim4).Render("PIPELINE")) - rows = append(rows, "") - for _, p := range m.phases { - icon := fg(cFaint).Render("○") - nameStyle := fg(cDim3) - switch { - case p.finished: - icon = fg(cAccent).Render("✓") - nameStyle = fg(cMuted2) - case p.active: - icon = fg(cTextHi).Render(m.spinner()) - nameStyle = fg(cTextHi) - } - meta := fg(cDim4).Render(fmt.Sprintf("%d/%d", min(p.done, p.total), p.total)) - if !p.pkg { - done := 0 - if p.finished { - done = 1 - } - meta = fg(cDim4).Render(fmt.Sprintf("%d/%d", done, p.total)) - } - left := " " + icon + " " + nameStyle.Render(p.name) - rows = append(rows, bar(left, meta+" ", pipelineW)) - } - for len(rows) < h { - rows = append(rows, "") - } - return rows -} - -// logView renders the tail of the log, bottom-aligned within h rows. -func (m Model) logView(w, h int) []string { - rows := make([]string, 0, h) - start := 0 - if len(m.logs) > h { - start = len(m.logs) - h - } - // top padding so the log sits at the bottom of the pane - for i := 0; i < h-(len(m.logs)-start); i++ { - rows = append(rows, "") - } - for _, l := range m.logs[start:] { - line := " " + fg(l.markColor).Render(l.mark) + " " + fg(l.color).Render(l.text) - rows = append(rows, truncCell(line, w)) - } - return rows -} - -func (m Model) installFooter(w int) []string { - if m.done { - pkgN := m.pkgCount() - m.skippedPkgs - head := fg(cAccent).Render("✓") + " " + fg(cTextHi).Bold(true).Render("This Mac is dev-ready.") + - " " + fg(cDim3).Render(fmt.Sprintf("%d packages · %s", pkgN, fmtElapsed(m.elapsed()))) - if m.installErr != nil { - head = fg(cWarn).Render("!") + " " + fg(cTextHi).Bold(true).Render("Finished with some errors.") + - " " + fg(cDim3).Render("see log above") - } - next := fg(cDim2).Render("next → ") + fg(cAccentHi).Render("openboot snapshot publish") + - fg(cDim3).Render(" keeps this setup synced to your account") - return []string{truncCell(head, w), truncCell(next, w)} - } - - // Active install bar: spinner, current step, progress bar, percent. - total := m.totalSteps() - completed := m.completedSteps() - pct := 0 - if total > 0 { - pct = completed * 100 / total - } - cells := 24 - fill := 0 - if total > 0 { - fill = completed * cells / total - } - barStr := fg(cAccent).Render(strings.Repeat("▰", fill)) + fg(cFaint).Render(strings.Repeat("▰", cells-fill)) - step := m.curStep - if step == "" { - step = "starting…" - } - left := fg(cAccent).Render(m.spinner()) + " " + fg(cMuted).Bold(true).Render(padTo(truncCell(step, 18), 18)) + " " + barStr - right := fg(cMuted3).Render(fmt.Sprintf("%d%%", pct)) - return []string{bar(left, right, w)} -} diff --git a/internal/ui/tui/wizard/plan.go b/internal/ui/tui/wizard/plan.go new file mode 100644 index 0000000..ec7f2fb --- /dev/null +++ b/internal/ui/tui/wizard/plan.go @@ -0,0 +1,60 @@ +package wizard + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/openbootdotdev/openboot/internal/installer" +) + +// The wizard plans; it does not install. Everything the user decides is +// resolved into an InstallPlan here, and Run hands that plan back to the CLI, +// which applies it on the normal terminal. +// +// The apply deliberately does NOT happen inside the alt-screen. A full-screen +// install looks good while it runs and then takes its own output with it: the +// alt-screen is torn down on exit and twenty minutes of package results vanish, +// leaving "check ~/.openboot/logs" as the only recourse. Streaming the apply +// into the scrollback instead keeps the record where the user already looks — +// scroll up, copy the error, pipe it — which is the whole point of a CLI. + +// buildPlan resolves the current selection into an install plan — from the +// remote config in config mode, from the catalog selection otherwise. +func (m Model) buildPlan() installer.InstallPlan { + if m.rc != nil { + return installer.PlanForRemoteSelection(m.opts, m.rc, m.selected, m.selectedOnlinePkgs()) + } + return installer.PlanFromSelection(m.opts, m.selected, m.selectedOnlinePkgs()) +} + +// finish accepts the reviewed plan and closes the wizard so the CLI can apply +// it. Confirm-screen toggles are folded in here: a step switched off must not +// reach the plan the installer receives. +func (m Model) finish() (tea.Model, tea.Cmd) { + plan := m.buildPlan() + + // A git identity captured on the git screen (fresh Mac). When git is already + // configured, these stay empty and the plan's existing config is used. + if strings.TrimSpace(m.gitName) != "" && strings.TrimSpace(m.gitEmail) != "" { + plan.GitName = m.gitName + plan.GitEmail = m.gitEmail + plan.SkipGit = false + } + if !m.confShell { + plan.InstallOhMyZsh = false + plan.ShellTheme = "" + plan.ShellPlugins = nil + } + if !m.confDotfiles { + plan.DotfilesURL = "" + } + if !m.confPrefs { + plan.MacOSPrefs = nil + } + + m.plan = plan + m.confirmed = true + m.quit = true + return m, tea.Quit +} diff --git a/internal/ui/tui/wizard/review_fixes_test.go b/internal/ui/tui/wizard/review_fixes_test.go index dcf900f..f993eee 100644 --- a/internal/ui/tui/wizard/review_fixes_test.go +++ b/internal/ui/tui/wizard/review_fixes_test.go @@ -9,70 +9,12 @@ import ( "github.com/stretchr/testify/require" "github.com/openbootdotdev/openboot/internal/config" - "github.com/openbootdotdev/openboot/internal/progress" ) -func npmEvent(name string, status progress.Status, detail string) evMsg { - return evMsg{ev: progress.Event{Phase: progress.PhaseNpm, Name: name, Status: status, Detail: detail}} -} - -// M1: the npm outer retry re-runs the install and re-emits an "already -// installed" skip for every package a prior attempt installed. The renderer -// must not double-count those, or the completion footer subtracts an inflated -// skip count from the package total and renders a negative "N packages". -func TestNpmRetrySkipsDoNotInflatePackageCount(t *testing.T) { - m := New("1", &config.InstallOptions{}) - m.screen, m.installing = scrInstall, true - m.phases = toPhaseStates([]PipelinePhase{{Name: progress.PhaseNpm, Total: 2, Pkg: true}}) - - // Attempt 1: pkgA installs, pkgB fails. - m = send(m, npmEvent("pkgA", progress.StepOK, "")) - m = send(m, npmEvent("pkgB", progress.StepFail, "boom")) - // Attempt 2: the retry re-scans and re-emits pkgA as already-installed while - // pkgB now installs. - m = send(m, npmEvent("pkgA", progress.StepOK, progress.SkipDetail)) - m = send(m, npmEvent("pkgB", progress.StepOK, "")) - - assert.Equal(t, 0, m.skippedPkgs, "a re-emitted skip for an already-counted package must not count") - assert.GreaterOrEqual(t, m.pkgCount()-m.skippedPkgs, 0, "footer package tally must never go negative") -} - -// The dedup must not swallow a genuine already-installed skip (the common, -// non-retry case where a package really was present before this run). -func TestGenuinePreInstalledSkipStillCounts(t *testing.T) { - m := New("1", &config.InstallOptions{}) - m.screen, m.installing = scrInstall, true - m.phases = toPhaseStates([]PipelinePhase{{Name: progress.PhaseNpm, Total: 1, Pkg: true}}) - - m = send(m, npmEvent("pkgA", progress.StepOK, progress.SkipDetail)) - assert.Equal(t, 1, m.skippedPkgs, "a genuine already-installed skip counts once") -} - -// C3: the elapsed clock on the completion footer must freeze once the install -// is done, not keep counting up while the user reads it. -func TestElapsedFreezesWhenDone(t *testing.T) { - m := New("1", &config.InstallOptions{}) - m.screen, m.installing = scrInstall, true - - for i := 0; i < 10; i++ { - m = send(m, tickMsg{}) - } - require.Equal(t, 10, m.ticks) - - m = send(m, installDoneMsg{}) - require.True(t, m.done) - frozen := m.elapsed() - - next, cmd := m.Update(tickMsg{}) - m = next.(Model) - assert.Nil(t, cmd, "the tick loop stops re-arming once done") - assert.Equal(t, 10, m.ticks, "ticks frozen at completion") - assert.Equal(t, frozen, m.elapsed(), "elapsed clock frozen at completion") -} +// Regressions from the v0.63 review that still have a home: the install-screen +// ones (npm skip dedup, elapsed freeze) went with the screen itself when the +// apply moved back to the scrollback. -// M2: a resize must re-clamp the stored scroll, so selectHitTest (which reads -// the stored scroll) agrees with selectList (which renders from a re-clamped -// copy). Without it, a click right after a resize toggles the wrong package. func TestWindowResizeReclampsSelectScroll(t *testing.T) { m := New("1", &config.InstallOptions{}) m = send(m, tea.WindowSizeMsg{Width: 96, Height: 40}) diff --git a/internal/ui/tui/wizard/status.go b/internal/ui/tui/wizard/status.go index 5c228e0..8a38703 100644 --- a/internal/ui/tui/wizard/status.go +++ b/internal/ui/tui/wizard/status.go @@ -28,20 +28,8 @@ func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right s case scrGit: return "GIT", cAccent, "↑↓/tab switch field · ↵ continue · esc back", "identity for your commits" - case scrConfirm: + default: // scrConfirm return "REVIEW", cAccent, "↑↓ move · space toggle · ↵ install · esc back", fmt.Sprintf("%d pkgs · ~%d min", m.selCount(), m.estMin()) - - default: // scrInstall - if m.done { - return "DONE", cAccent, "r replay from boot · q quit", - fmt.Sprintf("%d steps · %s", m.totalSteps(), fmtElapsed(m.elapsed())) - } - if m.aborting { - return "ABORT", cDanger, "aborting — waiting for the current step to stop · ctrl+c again to force quit", - fmt.Sprintf("%d/%d · %s", m.completedSteps(), m.totalSteps(), fmtElapsed(m.elapsed())) - } - return "INSTALL", cWarn, "installing — everything is logged to ~/.openboot/logs", - fmt.Sprintf("%d/%d · %s", m.completedSteps(), m.totalSteps(), fmtElapsed(m.elapsed())) } } diff --git a/internal/ui/tui/wizard/styles.go b/internal/ui/tui/wizard/styles.go index 1e3ebe1..1529b57 100644 --- a/internal/ui/tui/wizard/styles.go +++ b/internal/ui/tui/wizard/styles.go @@ -2,37 +2,60 @@ package wizard import ( "strings" - "sync" "github.com/charmbracelet/lipgloss" ) -// Palette — mirrors the OpenBoot TUI Redesign v5 design tokens. The two anchor -// colors (accent green #22c55e, info cyan #06b6d4) already match the existing -// internal/ui palette; the rest are the grey ramp and status hues the design -// leans on for depth. +// Palette. +// +// Rule: never hardcode a colour whose job is to contrast with the background. +// We don't know the background — the user picks it, and it may be translucent +// over a wallpaper, which lifts the effective background far above whatever a +// design mock assumed. +// +// The Redesign v5 tokens were a 10-step hex grey ramp mocked against #08080a. +// Measured against a translucent terminal (effective bg ≈ #2b4247), the bottom +// half of that ramp collapsed into the background — #3f3f46 landed at 1.0:1, +// i.e. the exact luminance of the background, which no terminal can render as +// visible text. Pending pipeline rows, sidebar counts and key hints simply +// disappeared. (v0.63's own greys — #444/#555/#666 — measure 1.1/1.4/1.9:1 +// there and fail the same way; it only looked better because it used the +// terminal's default foreground for most text.) +// +// So the text ramp is now the terminal's own ANSI palette, which the user's +// theme guarantees is legible against the background they chose: +// +// 15 (bright white) — emphasis: cursor row, active phase, headings +// 7 (white) — normal body text and anything load-bearing +// 8 (bright black) — decorative only: rules, placeholders, spent progress +// +// State is carried by glyph + accent hue (○ / spinner / ✓), never by fading a +// label toward the background — that reads as "missing", not as "pending". +// +// The brand/status hues stay hex: they're bright enough to hold up anywhere +// (measured 4.4–6.1:1 on the same translucent terminal) and they're the +// product's identity, shared with the internal/ui palette. var ( cAccent = lipgloss.Color("#22c55e") // primary green cAccentHi = lipgloss.Color("#4ade80") // bright green — times, links cInfo = lipgloss.Color("#06b6d4") // cyan — probing / active spinner - cWarn = lipgloss.Color("#f59e0b") // amber — installing / active search - cDanger = lipgloss.Color("#ef4444") // red — failures + cWarn = lipgloss.Color("#f59e0b") // amber — active search - cWhite = lipgloss.Color("#ffffff") // cursor row name - cTextHi = lipgloss.Color("#e4e4e7") // emphasized body text - cText = lipgloss.Color("#d4d4d8") // body text - cMuted = lipgloss.Color("#a1a1aa") // secondary text - cMuted2 = lipgloss.Color("#8a8a92") - cMuted3 = lipgloss.Color("#71717a") - cDim = lipgloss.Color("#63636c") - cDim2 = lipgloss.Color("#52525b") - cDim3 = lipgloss.Color("#3f3f46") - cDim4 = lipgloss.Color("#3a3a41") - cFaint = lipgloss.Color("#2e2e34") + // Text ramp — terminal-relative. Names kept so call sites read the same. + cWhite = lipgloss.Color("15") // cursor row name + cTextHi = lipgloss.Color("15") // emphasized body text + cText = lipgloss.Color("7") // body text + cMuted = lipgloss.Color("7") // secondary text + cMuted2 = lipgloss.Color("7") // finished phase names + cMuted3 = lipgloss.Color("7") // status-bar info — teaches keys, must read + cDim = lipgloss.Color("7") // package descriptions — chosen from, must read + cDim2 = lipgloss.Color("7") // status-bar key hints — must read + cDim3 = lipgloss.Color("8") // version, crumb, unselected box + cDim4 = lipgloss.Color("8") // pane headings, counts, placeholders + cFaint = lipgloss.Color("8") // spent progress cells, pending glyph - cInstalled = lipgloss.Color("#3f6b4a") // dim green — already-installed rows - cBorder = lipgloss.Color("#2b2b33") // panel dividers (brighter than the - // design's #1c1c22 so it reads in a terminal) + cInstalled = lipgloss.Color("2") // green — already-installed rows + cBorder = lipgloss.Color("8") // panel dividers ) // fg returns a foreground-only style for c. @@ -40,35 +63,20 @@ func fg(c lipgloss.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } -// cHover is the row-hover background (#3d3d4a in the design). -var cHover = lipgloss.Color("#3d3d4a") - -// hoverBgSeq is the raw background escape for cHover under the terminal's -// actual colour profile, extracted once from a lipgloss render so it -// downsamples on 256/16-colour terminals and disappears entirely (empty -// string) when colour isn't supported — instead of hardcoding a truecolor -// sequence that those terminals would mangle. -var hoverBgSeq = sync.OnceValue(func() string { - const probe = "\x01" - rendered := lipgloss.NewStyle().Background(cHover).Render(probe) - i := strings.Index(rendered, probe) - if i <= 0 { - return "" - } - return rendered[:i] -}) - -// hoverBg paints the hover background across the whole row. lipgloss ends -// every styled span with a FULL reset (\e[0m), which also clears the -// background — so re-establish the background after each reset, otherwise -// only the first span would be highlighted. +// hoverBg marks the row under the mouse pointer using reverse video (SGR 7) +// rather than a painted background colour. Any specific background we picked +// would be a guess about the user's — the design's #3d3d4a sat at 1.2:1 +// against a translucent terminal, so the "highlight" was a no-op there. +// Reverse swaps whatever fg/bg the row already has, so it is guaranteed +// visible in every theme, at every colour depth, including monochrome. +// +// lipgloss ends each styled span with a FULL reset (\e[0m), which also clears +// the reverse attribute — so re-establish it after every reset, otherwise only +// the first span would be marked. func hoverBg(s string) string { - bg := hoverBgSeq() - if bg == "" { - return s // colourless terminal — hover simply has no visual, nothing breaks - } - s = strings.ReplaceAll(s, "\x1b[0m", "\x1b[0m"+bg) - return bg + s + "\x1b[0m" + const rev = "\x1b[7m" + s = strings.ReplaceAll(s, "\x1b[0m", "\x1b[0m"+rev) + return rev + s + "\x1b[0m" } // spinnerFrames matches the braille spinner used across the codebase and the design. diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go index 4b0b965..91c0940 100644 --- a/internal/ui/tui/wizard/wizard.go +++ b/internal/ui/tui/wizard/wizard.go @@ -1,19 +1,20 @@ -// Package wizard implements the OpenBoot install TUI (Redesign v5): a single -// full-screen bubbletea program that flows boot-probe → two-pane select → -// live pipeline install, under a persistent title bar and status bar. +// Package wizard implements the OpenBoot install planner: a full-screen +// bubbletea program that flows boot-probe → two-pane select → git → review, +// under a persistent title bar and status bar. // -// It replaces the previous interactive planning prompts (preset select, package -// selector, per-step confirms) and the linear Apply output. Preset installs -// (-p) enter it with the loadout preselected; remote-config installs (slug, -// -u, --from, alias) enter config mode via RunForConfig, with the config's own -// packages on the select screen. Non-interactive paths (--silent, --dry-run, -// --update, no TTY) never reach the wizard; sync-source installs keep their -// linear diff pre-flight and reuse only the live install screen (RunPipeline). +// It replaces the interactive planning prompts (preset select, package +// selector, per-step confirms) that used to flip between full-screen and inline +// several times per run. It deliberately stops at the plan: the apply runs +// after the alt-screen is gone, streaming into the scrollback, because an +// alt-screen install takes its own output with it when it exits. +// +// Preset installs (-p) enter with the loadout preselected; remote-config +// installs (slug, -u, --from, alias) enter config mode via RunForConfig, with +// the config's own packages on the select screen. Non-interactive paths +// (--silent, --dry-run, --update, no TTY) never reach the wizard. package wizard import ( - "context" - "errors" "fmt" "os" "strings" @@ -33,7 +34,6 @@ const ( scrSelect scrGit scrConfirm - scrInstall ) // focusPane is which of the two select-screen columns holds keyboard focus. @@ -45,11 +45,7 @@ const ( focusCats // category sidebar (left column) ) -// ErrAborted is returned by Run when the user cancels a running install with -// ctrl+c. It distinguishes a deliberate abort from install failures. -var ErrAborted = errors.New("installation aborted") - -// tickInterval drives the spinner and the derived elapsed clock. +// tickInterval drives the boot-probe spinner. const tickInterval = 120 * time.Millisecond type tickMsg struct{} @@ -108,44 +104,25 @@ type Model struct { confPrefs bool confCur int - // ── install ── - events chan tea.Msg - installDone chan struct{} // closed by the install goroutine once ApplyContext returns (Run joins on it) - plan installer.InstallPlan - phases []phaseState - logs []logLine - curStep string - installing bool - aborting bool // ctrl+c received mid-install; waiting for the engine to stop - done bool - installErr error - skippedPkgs int // terminal events with SkipDetail (already installed) - failedPkgs []string // packages whose terminal event was StepFail, for the completion summary - terminalSeen map[string]bool // packages that produced a terminal event, for skip dedup (npm retry) - installTick int // ticks value when install started, for elapsed - cancel context.CancelFunc - - // ── pipeline mode (RunPipeline: sync-source & slug installs reuse this screen) ── - pipelineRun func(context.Context, installer.Reporter) error // non-nil ⇒ start on install screen - pipelineCtx context.Context + // ── result ── + // plan is what the CLI applies once the wizard exits; confirmed says the + // user reviewed it and pressed ↵ rather than quitting. + plan installer.InstallPlan } // New builds a wizard model for the given version and resolved install options. func New(version string, opts *config.InstallOptions) Model { return Model{ - version: version, - opts: opts, - screen: scrBoot, - probes: newProbes(), - loadouts: newLoadouts(), - installed: map[string]bool{}, - cats: config.GetCategories(), - hoverRow: -1, - selected: map[string]bool{}, - onlineKnown: map[string]bool{}, - events: make(chan tea.Msg, 1024), - installDone: make(chan struct{}), - terminalSeen: map[string]bool{}, + version: version, + opts: opts, + screen: scrBoot, + probes: newProbes(), + loadouts: newLoadouts(), + installed: map[string]bool{}, + cats: config.GetCategories(), + hoverRow: -1, + selected: map[string]bool{}, + onlineKnown: map[string]bool{}, } } @@ -177,11 +154,6 @@ func configLabel(rc *config.RemoteConfig) string { } func (m Model) Init() tea.Cmd { - if m.pipelineRun != nil { - // waitForEvent is essential: it drains m.events into Update. Without it - // the goroutine's installDoneMsg is never read and the screen hangs. - return tea.Batch(tickCmd(), m.startPipeline(), waitForEvent(m.events)) - } return tea.Batch(tickCmd(), m.runProbe(0)) } @@ -189,11 +161,6 @@ func tickCmd() tea.Cmd { return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} }) } -// waitForEvent blocks on the install event channel and delivers the next msg. -func waitForEvent(ch chan tea.Msg) tea.Cmd { - return func() tea.Msg { return <-ch } -} - func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -205,19 +172,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.clampSelScroll(), nil case tickMsg: - // Stop animating once the install screen is done: nothing on it spins, and - // leaving the clock running makes the completion footer's elapsed time keep - // counting up while the user reads it. Checking before the increment - // freezes elapsed() exactly at completion. - if m.done { - return m, nil - } m.ticks++ return m, tickCmd() case tea.KeyMsg: if msg.String() == "ctrl+c" { - return m.onCtrlC() + // Nothing has been applied yet — the wizard only plans — so quitting + // is always clean and needs no confirmation. + m.quit = true + return m, tea.Quit } switch m.screen { case scrBoot: @@ -228,8 +191,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateGit(msg) case scrConfirm: return m.updateConfirm(msg) - case scrInstall: - return m.updateInstall(msg) } case tea.MouseMsg: @@ -244,35 +205,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case searchDoneMsg: return m.onSearchDone(msg) - case evMsg, reporterMsg, installDoneMsg: - return m.onInstallEvent(msg) } return m, nil } -// onCtrlC implements the global abort semantics. The first ctrl+c during a -// running install requests an abort: cancel the context and keep the TUI up -// until the engine reports back (installDoneMsg), so the goroutine is joined -// and the abort is reported honestly. A second ctrl+c force-quits; outside an -// install it quits immediately. -func (m Model) onCtrlC() (tea.Model, tea.Cmd) { - if m.screen == scrInstall && m.installing && !m.aborting { - m.aborting = true - if m.cancel != nil { - m.cancel() - } - return m, nil - } - if m.cancel != nil { - m.cancel() - } - if m.installing { - m.installErr = ErrAborted - } - m.quit = true - return m, tea.Quit -} - // routeMouse dispatches a mouse event to the active screen's handler. func (m Model) routeMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { switch m.screen { @@ -284,8 +220,6 @@ func (m Model) routeMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m.updateGitMouse(msg) case scrConfirm: return m.updateConfirmMouse(msg) - case scrInstall: - return m.updateInstallMouse(msg) } return m, nil } @@ -326,8 +260,6 @@ func (m Model) View() string { body = m.gitBody(m.width, bodyH) case scrConfirm: body = m.confirmBody(m.width, bodyH) - case scrInstall: - body = m.installBody(m.width, bodyH) } return m.titleBar() + "\n" + fitBlock(body, m.width, bodyH) + "\n" + m.statusBar() @@ -365,13 +297,8 @@ func (m Model) crumb() string { return "select packages" case scrGit: return "git identity" - case scrConfirm: - return "review plan" default: - if m.done { - return "done" - } - return "installing" + return "review plan" } } @@ -455,96 +382,27 @@ func truncCell(s string, w int) string { return lipgloss.NewStyle().MaxWidth(w).Render(s) } -// Run launches the wizard. It returns the applied plan, whether an install was -// started (confirmed), and any error from the install run (ErrAborted when the -// user cancelled mid-install). Stray stdout from the install engine is -// redirected away from the alt-screen for the program's lifetime; the abort -// flow keeps the TUI alive until the engine goroutine reports done, so the -// redirect isn't restored under its feet. +// Run launches the wizard: boot probe → select → git → review. It installs +// nothing; it returns the reviewed plan and whether the user confirmed it, and +// the caller applies that plan on the normal terminal once the alt-screen is +// gone. func Run(version string, opts *config.InstallOptions) (plan installer.InstallPlan, confirmed bool, err error) { return runProgram(New(version, opts)) } // RunForConfig launches the wizard in config mode for a fetched remote config -// (install / -u / --from / alias): boot probe → select (the config's -// packages, preselected) → review → live install. Returns like Run; the -// returned plan keeps the config's post-install script for the CLI to run -// after teardown (the alt-screen can't host its confirm prompt). +// (install / -u / --from / alias): the select screen shows the config's +// own packages, preselected. Returns like Run. func RunForConfig(version string, opts *config.InstallOptions, rc *config.RemoteConfig) (plan installer.InstallPlan, confirmed bool, err error) { return runProgram(NewForConfig(version, opts, rc)) } func runProgram(m Model) (plan installer.InstallPlan, confirmed bool, err error) { - realOut, restore := redirectOutput() - defer restore() - - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion(), tea.WithOutput(realOut), tea.WithInput(os.Stdin)) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion(), tea.WithInput(os.Stdin)) final, runErr := p.Run() if runErr != nil { return installer.InstallPlan{}, false, fmt.Errorf("run wizard: %w", runErr) } fm := final.(Model) - // Join the install goroutine before the deferred restore() flips os.Stdout - // back, so a force-quit (2nd ctrl+c) can neither race the engine's stdout - // writes nor return control to the shell while it is still mutating the system. - fm.joinInstall() - return fm.plan, fm.confirmed, fm.installErr -} - -// joinInstall blocks until the install goroutine has finished (it closes -// installDone once ApplyContext returns and the brew/npm sinks are restored). -// No-op when no install was started. The timeout is a safety valve so a wedged -// subprocess can't hang the process on the terminal indefinitely. -func (m Model) joinInstall() { - if !m.confirmed && m.pipelineRun == nil { - return // no install goroutine was ever spawned - } - select { - case <-m.installDone: - case <-time.After(30 * time.Second): - } -} - -// redirectOutput sends stdout+stderr to /dev/null for the alt-screen's lifetime -// (subprocess progress must not paint over Bubble Tea) and returns the real -// stdout to render onto plus a restore func. -func redirectOutput() (realOut *os.File, restore func()) { - realOut, realErr := os.Stdout, os.Stderr - if devnull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0); err == nil { - os.Stdout, os.Stderr = devnull, devnull - return realOut, func() { - os.Stdout, os.Stderr = realOut, realErr - _ = devnull.Close() - } - } - return realOut, func() {} -} - -// RunPipeline renders the wizard's live install screen for an externally-built -// plan, so the sync-source path (install ) shares the wizard's install -// visuals instead of the linear StickyProgress. phases seed the pipeline -// sidebar; run does the work on a goroutine, its package progress streaming -// through the brew/npm sinks RunPipeline registers. Returns run's error -// (ErrAborted on ctrl+c). -func RunPipeline(version string, phases []PipelinePhase, run func(context.Context, installer.Reporter) error) error { - m := New(version, &config.InstallOptions{Version: version}) - m.screen = scrInstall - m.installing = true - m.phases = toPhaseStates(phases) - ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel is stored on the model and called on ctrl+c / done - m.cancel = cancel - m.pipelineCtx = ctx - m.pipelineRun = run - - realOut, restore := redirectOutput() - defer restore() - - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion(), tea.WithOutput(realOut), tea.WithInput(os.Stdin)) - final, runErr := p.Run() - if runErr != nil { - return fmt.Errorf("run install: %w", runErr) - } - fm := final.(Model) - fm.joinInstall() // see Run: join before the deferred restore() - return fm.installErr + return fm.plan, fm.confirmed, nil } diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index 180decc..070e5dd 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -1,12 +1,8 @@ package wizard import ( - "context" - "errors" - "io" "strings" "testing" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -14,9 +10,6 @@ import ( "github.com/stretchr/testify/require" "github.com/openbootdotdev/openboot/internal/config" - "github.com/openbootdotdev/openboot/internal/installer" - "github.com/openbootdotdev/openboot/internal/macos" - "github.com/openbootdotdev/openboot/internal/progress" ) func sized(w, h int) Model { @@ -356,14 +349,14 @@ func TestGitCaptureWhenUnconfigured(t *testing.T) { m = send(m, key(string(r))) } // Enter on the email field with both filled proceeds to the confirm - // screen; a second enter starts the install. + // screen; a second enter accepts the plan and closes the wizard. next, _ := m.Update(key("enter")) m = next.(Model) require.Equal(t, scrConfirm, m.screen, "git capture flows into the review screen") next, _ = m.Update(key("enter")) m = next.(Model) - require.Equal(t, scrInstall, m.screen) + require.True(t, m.confirmed) assert.Equal(t, "Jane Dev", m.plan.GitName) assert.Equal(t, "jane@ex.io", m.plan.GitEmail) assert.False(t, m.plan.SkipGit) @@ -401,44 +394,12 @@ func TestConfirmtogglesGateThePlan(t *testing.T) { next, _ := m.Update(key("enter")) m = next.(Model) - require.Equal(t, scrInstall, m.screen) + require.True(t, m.confirmed) assert.False(t, m.plan.InstallOhMyZsh, "shell toggled off") assert.Empty(t, m.plan.DotfilesURL, "dotfiles toggled off") assert.Empty(t, m.plan.MacOSPrefs, "prefs toggled off") } -// ctrl+c during a running install must request an abort (stay in the TUI, -// cancel the context) and only quit once the engine reports done — with a -// non-nil ErrAborted so the CLI exits non-zero. -func TestCtrlCDuringInstallAbortsHonestly(t *testing.T) { - plan := installer.InstallPlan{Formulae: []string{"a"}, SkipGit: true} - m := New("1", &config.InstallOptions{}) - m.screen = scrInstall - m.installing = true - m.phases = buildPhases(plan) - m.cancel = func() {} - - next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) - m = next.(Model) - assert.Nil(t, cmd, "first ctrl+c must not quit — it waits for the engine") - require.True(t, m.aborting) - - // Cancelling the context SIGKILLs the in-flight brew/npm subprocess, so the - // engine reports a NON-nil error, not a clean nil. The abort must still be - // recognised as ErrAborted — the regression was that the old guard only set - // ErrAborted when the error happened to be nil, so an abort during the - // package phase (the common case) was misreported as an install failure. - killErr := errors.New("signal: killed") - next, _ = m.Update(installDoneMsg{err: killErr}) - m = next.(Model) - assert.ErrorIs(t, m.installErr, ErrAborted, "abort with a killed subprocess is still ErrAborted") - assert.ErrorIs(t, m.installErr, killErr, "underlying cause stays in the chain for the log") - assert.True(t, m.quit) - for _, p := range m.phases { - assert.False(t, p.finished, "aborted phases must not show as finished") - } -} - func TestGitScreenEscReturnsToSelect(t *testing.T) { defer stubGitConfig("", "")() m := finishProbes(sized(96, 30)) @@ -466,138 +427,6 @@ func stubGitConfig(name, email string) func() { return func() { gitConfigLookup = prev } } -func TestBuildPhases(t *testing.T) { - plan := installer.InstallPlan{ - Formulae: []string{"a", "b"}, - Casks: []string{"c"}, - Npm: []string{"d"}, - InstallOhMyZsh: true, - DotfilesURL: "x", - MacOSPrefs: make([]macos.Preference, 1), - } - phases := buildPhases(plan) - var names []string - for _, p := range phases { - names = append(names, p.name) - } - assert.Equal(t, []string{ - "Git identity", progress.PhaseHomebrew, progress.PhaseApplications, - progress.PhaseNpm, "Shell", "Dotfiles", "macOS prefs", - }, names) - - // PackagesOnly drops every config phase. - po := buildPhases(installer.InstallPlan{Formulae: []string{"a"}, PackagesOnly: true}) - require.Len(t, po, 1) - assert.Equal(t, progress.PhaseHomebrew, po[0].name) -} - -// The streaming invariant: every planned package produces exactly one terminal -// event, so totals count the full plan and already-installed skips arrive as -// StepOK events with SkipDetail. -func TestSkipEventsCompletePhaseAndCountSkipped(t *testing.T) { - plan := installer.InstallPlan{Formulae: []string{"a", "b", "c"}, SkipGit: true} - m := New("1", &config.InstallOptions{}) - m.screen = scrInstall - m.phases = buildPhases(plan) - require.Equal(t, 3, m.phases[0].total, "totals count every planned package") - - feed := func(ev progress.Event) { - next, _ := m.Update(evMsg{ev: ev}) - m = next.(Model) - } - feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepOK, Detail: progress.SkipDetail}) - feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "b", Status: progress.StepOK, Detail: progress.SkipDetail}) - feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "c", Status: progress.StepOK, Detail: "1.2s"}) - - assert.True(t, m.phases[0].finished, "skips + installs complete the phase") - assert.Equal(t, 2, m.skippedPkgs) - assert.Equal(t, 1, m.pkgCount()-m.skippedPkgs, "DONE footer counts actual installs") -} - -// A retry pass emits a second terminal event for the same package; done must -// clamp at total instead of overrunning. -func TestIncPhaseClampsOnRetryEvents(t *testing.T) { - plan := installer.InstallPlan{Formulae: []string{"a"}, SkipGit: true} - m := New("1", &config.InstallOptions{}) - m.screen = scrInstall - m.phases = buildPhases(plan) - - feed := func(ev progress.Event) { - next, _ := m.Update(evMsg{ev: ev}) - m = next.(Model) - } - feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepFail, Detail: "timeout"}) - feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepOK, Detail: "retry succeeded"}) - assert.Equal(t, 1, m.phases[0].done, "retry event must not overrun the total") - assert.Equal(t, 1, m.completedSteps()) -} - -func TestProgressEventsDrivePhasesAndLog(t *testing.T) { - // SkipGit drops the "Git identity" phase so the package phases lead. - plan := installer.InstallPlan{Formulae: []string{"a", "b"}, Casks: []string{"c"}, SkipGit: true} - m := New("1", &config.InstallOptions{}) - m.screen = scrInstall - m.phases = buildPhases(plan) - - feed := func(ev progress.Event) { - next, _ := m.Update(evMsg{ev: ev}) - m = next.(Model) - } - feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepStart, Command: "brew install a"}) - assert.True(t, phaseByName(m, progress.PhaseHomebrew).active, "homebrew active") - assert.Equal(t, "a", m.curStep) - - feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "a", Status: progress.StepOK, Detail: "1.0s"}) - feed(progress.Event{Phase: progress.PhaseHomebrew, Name: "b", Status: progress.StepOK, Detail: "2.0s"}) - assert.True(t, phaseByName(m, progress.PhaseHomebrew).finished, "homebrew finished at 2/2") - assert.Equal(t, 2, m.completedSteps()) - - feed(progress.Event{Phase: progress.PhaseApplications, Name: "c", Status: progress.StepStart, Command: "brew install --cask c"}) - assert.True(t, phaseByName(m, progress.PhaseApplications).active) - - // Log carries $cmd and ✓result lines. - joined := strings.Join(logTexts(m.logs), "\n") - assert.Contains(t, joined, "brew install a") - assert.Contains(t, joined, "a — 1.0s") -} - -func phaseByName(m Model, name string) phaseState { - for _, p := range m.phases { - if p.name == name { - return p - } - } - return phaseState{} -} - -func TestReporterHeaderActivatesConfigPhase(t *testing.T) { - plan := installer.InstallPlan{InstallOhMyZsh: true, SkipGit: true} - m := New("1", &config.InstallOptions{}) - m.screen = scrInstall - m.phases = buildPhases(plan) - require.Len(t, m.phases, 1) - - next, _ := m.Update(reporterMsg{kind: rHeader, text: "Shell Configuration"}) - m = next.(Model) - assert.True(t, m.phases[0].active, "shell header activates the Shell phase") -} - -func TestInstallDoneMarksAllFinished(t *testing.T) { - plan := installer.InstallPlan{Formulae: []string{"a"}, InstallOhMyZsh: true} - m := New("1", &config.InstallOptions{}) - m.screen = scrInstall - m.installing = true - m.phases = buildPhases(plan) - - next, _ := m.Update(installDoneMsg{}) - m = next.(Model) - assert.False(t, m.installing) - assert.True(t, m.done) - for _, p := range m.phases { - assert.True(t, p.finished) - } -} - // TestViewDimensions asserts every screen fills exactly the terminal box. func TestViewDimensions(t *testing.T) { const W, H = 90, 28 @@ -608,7 +437,9 @@ func TestViewDimensions(t *testing.T) { gitCase := send(finishProbes(sized(W, H)), key("2")) gitCase.screen = scrGit cases["git"] = gitCase - cases["install"] = installFrame(finishProbes(sized(W, H)), W, H) + confirmCase := send(finishProbes(sized(W, H)), key("2")) + confirmCase, _ = func() (Model, tea.Cmd) { m, c := confirmCase.enterConfirm(); return m.(Model), c }() + cases["confirm"] = confirmCase for name, m := range cases { t.Run(name, func(t *testing.T) { @@ -626,160 +457,7 @@ func TestViewEmptyBeforeSize(t *testing.T) { assert.Empty(t, m.View(), "renders nothing until sized") } -// TestInstallGoroutineStreamsToDone exercises the real wiring end-to-end -// against a dry-run plan: spawnInstall's goroutine sets the brew/npm sinks, -// runs installer.ApplyContext with the channel Reporter, and the model drains -// the channel through Update until installDoneMsg. Dry-run means nothing is -// actually installed. -func TestInstallGoroutineStreamsToDone(t *testing.T) { - opts := &config.InstallOptions{Version: "1", DryRun: true} - m := New("1", opts) - m.screen = scrInstall - - plan := installer.PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil) - plan.Silent = true - m.plan = plan - m.phases = buildPhases(plan) - m.installing = true - - // Start the background install; the cmd returns nil and feeds m.events. - m.spawnInstall(context.Background(), plan)() - - // Drain the channel through Update until the install reports done. - deadline := time.After(30 * time.Second) - for !m.done { - select { - case msg := <-m.events: - next, _ := m.Update(msg) - m = next.(Model) - case <-deadline: - t.Fatal("install did not complete within 30s") - } - } - - assert.False(t, m.installing) - assert.NoError(t, m.installErr) - // Every phase ends finished once done. - for _, p := range m.phases { - assert.Truef(t, p.finished, "phase %q finished", p.name) - } - // C2: the goroutine closes installDone once ApplyContext returns, so Run's - // joinInstall can wait for it before restoring os.Stdout (no force-quit race). - select { - case <-m.installDone: - default: - t.Fatal("installDone must be closed once the install goroutine finishes") - } -} - -// joinInstall must not block when no install goroutine was ever started (e.g. -// the user quit on the boot/select screen) — otherwise Run would hang 30s. -func TestJoinInstallNoopWhenNoInstall(t *testing.T) { - m := New("1", &config.InstallOptions{}) - returned := make(chan struct{}) - go func() { m.joinInstall(); close(returned) }() - select { - case <-returned: - case <-time.After(2 * time.Second): - t.Fatal("joinInstall must be a no-op when no install ran") - } -} - -func logTexts(ls []logLine) []string { - out := make([]string, len(ls)) - for i, l := range ls { - out[i] = l.text - } - return out -} - -// TestPipelineDrainsChannelAndCompletes runs a real (headless) program to catch -// the hang the unit tests below can't: pipeline-mode Init must arm waitForEvent -// so the goroutine's installDoneMsg is actually read. Without it, done never -// flips, the 'q' below is ignored (updateInstall quits only when done), and this -// test times out. (The send()-based tests inject installDoneMsg directly and so -// bypass — and miss — the Init→channel wiring.) -func TestPipelineDrainsChannelAndCompletes(t *testing.T) { - pr, pw := io.Pipe() - t.Cleanup(func() { _ = pw.Close() }) - - m := New("t", &config.InstallOptions{}) - m.screen, m.installing = scrInstall, true - m.phases = toPhaseStates([]PipelinePhase{{Name: "Homebrew", Total: 1, Pkg: true}}) - m.pipelineCtx = context.Background() - m.pipelineRun = func(context.Context, installer.Reporter) error { return nil } // completes instantly - - p := tea.NewProgram(m, tea.WithInput(pr), tea.WithOutput(io.Discard), tea.WithoutRenderer()) - done := make(chan tea.Model, 1) - go func() { fm, _ := p.Run(); done <- fm }() - - time.Sleep(300 * time.Millisecond) // let the goroutine finish + drain to done - _, _ = pw.Write([]byte("q")) // DONE screen: q quits - - select { - case fm := <-done: - assert.True(t, fm.(Model).done, "pipeline reached done via the drained channel") - case <-time.After(3 * time.Second): - p.Kill() - t.Fatal("pipeline hung — Init did not arm waitForEvent to drain m.events") - } -} - -// Pipeline mode (RunPipeline / sync-source path) reuses the install screen with -// externally-built phases; installDoneMsg must mark it done and, on a clean run, -// check-mark all phases. -func TestPipelineModeReachesDone(t *testing.T) { - m := New("1.0.0", &config.InstallOptions{}) - m.screen = scrInstall - m.installing = true - m.phases = toPhaseStates([]PipelinePhase{ - {Name: "Homebrew", Total: 2, Pkg: true}, - {Name: "macOS prefs", Total: 1}, - }) - - m = send(m, installDoneMsg{err: nil}) - assert.True(t, m.done, "installDoneMsg marks the screen done") - assert.False(t, m.installing) - for _, p := range m.phases { - assert.True(t, p.finished, "a clean run check-marks every phase (incl. config steps)") - } -} - -func TestPipelineReplayDisabled(t *testing.T) { - m := New("1.0.0", &config.InstallOptions{}) - m.screen, m.done = scrInstall, true - m.pipelineRun = func(context.Context, installer.Reporter) error { return nil } // marks pipeline mode - next, _ := m.Update(key("r")) - // 'r' must NOT restart the wizard probe in pipeline mode — screen stays put. - assert.Equal(t, scrInstall, next.(Model).screen, "replay is a no-op in pipeline mode") -} - -// PhasesForPlan drives the slug/preset pipeline sidebar from an installer plan. -func TestPhasesForPlan(t *testing.T) { - plan := installer.InstallPlan{ - Formulae: []string{"jq", "ripgrep"}, - Casks: []string{"orbstack"}, - InstallOhMyZsh: true, - DotfilesURL: "https://github.com/x/dotfiles", - } - ps := PhasesForPlan(plan) - - byName := map[string]PipelinePhase{} - for _, p := range ps { - byName[p.Name] = p - } - require.Contains(t, byName, "Homebrew") - assert.Equal(t, 2, byName["Homebrew"].Total) - assert.True(t, byName["Homebrew"].Pkg) - require.Contains(t, byName, "Applications") - require.Contains(t, byName, "Shell") - assert.False(t, byName["Shell"].Pkg, "config steps are not per-item package phases") - require.Contains(t, byName, "Dotfiles") - assert.NotContains(t, byName, "npm globals", "no npm in this plan") -} - -// ── deep-polish additions: small terminals, preset entry, online search, -// completion summary, hover colour degradation ── +// ── small terminals, preset entry, online search, palette ── func TestSmallTerminalShowsResizeHint(t *testing.T) { m := sized(48, 12) @@ -878,37 +556,33 @@ func TestOnlineSearchFindsTogglesAndSurvivesFilterClear(t *testing.T) { assert.Less(t, m.catCur, len(m.cats), "category cursor re-clamped") } -func TestCompletionSummaryListsFailures(t *testing.T) { - m := installFrame(sized(96, 30), 96, 30) - m = send(m, evMsg{ev: progress.Event{Phase: progress.PhaseHomebrew, Name: "ripgrep", Status: progress.StepFail, Detail: "build error"}}) - m = send(m, installDoneMsg{err: errors.New("1 package failed")}) - require.True(t, m.done) - - var logText strings.Builder - for _, l := range m.logs { - logText.WriteString(l.text + "\n") - } - assert.Contains(t, logText.String(), "installed · 0 already present", "summary counts land in the log") - assert.Contains(t, logText.String(), "1 failed: ripgrep", "failed package names are restated at the end") -} - -func TestCompletionSummaryCleanRun(t *testing.T) { - m := installFrame(sized(96, 30), 96, 30) - m = send(m, installDoneMsg{}) - var logText strings.Builder - for _, l := range m.logs { - logText.WriteString(l.text + "\n") - } - assert.Contains(t, logText.String(), "installed ·") - assert.NotContains(t, logText.String(), "failed:") -} - -func TestHoverBgFollowsColorProfile(t *testing.T) { - // Under `go test` stdout is a pipe, so the lipgloss profile is usually - // colourless — hover must become a no-op, never a hardcoded escape. - if seq := hoverBgSeq(); seq == "" { - assert.Equal(t, "row", hoverBg("row")) - } else { - assert.True(t, strings.HasPrefix(hoverBg("row"), seq)) +// Hover must not depend on a background colour we guessed: reverse video is +// defined at every colour depth, so the marker survives themes we can't see. +func TestHoverUsesReverseVideo(t *testing.T) { + const rev = "\x1b[7m" + out := hoverBg("row") + assert.True(t, strings.HasPrefix(out, rev), "row opens in reverse") + assert.True(t, strings.HasSuffix(out, "\x1b[0m"), "row closes with a reset") + + // A styled span's own reset must not silently end the highlight: reverse is + // re-opened after it (once to open the row, once after the inner reset — + // the row's own closing reset is appended afterwards and must stay bare). + styled := hoverBg("a" + "\x1b[0m" + "b") + assert.Equal(t, 2, strings.Count(styled, rev), "reverse re-established after the inner reset") + assert.False(t, strings.HasSuffix(styled, rev), "the closing reset is not re-opened") +} + +// The text ramp must stay terminal-relative: a hex grey is a guess about the +// user's background, and the guess is what made pending rows and key hints +// invisible on a translucent terminal. Brand hues are exempt — they're bright +// enough to carry anywhere and they're the product's identity. +func TestTextRampIsTerminalRelative(t *testing.T) { + for name, c := range map[string]lipgloss.Color{ + "cWhite": cWhite, "cTextHi": cTextHi, "cText": cText, "cMuted": cMuted, + "cMuted2": cMuted2, "cMuted3": cMuted3, "cDim": cDim, "cDim2": cDim2, + "cDim3": cDim3, "cDim4": cDim4, "cFaint": cFaint, "cBorder": cBorder, + "cInstalled": cInstalled, + } { + assert.NotContains(t, string(c), "#", "%s must use an ANSI index, not a hex guess at the background", name) } }