From a478992fed1a23a2ea3bbdbf385c13a0f4a2cef0 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Thu, 16 Jul 2026 01:13:40 +0800 Subject: [PATCH 1/4] fix(npm): carry per-package install durations in streaming events Sequential npm installs now time each package and emit the duration as the StepOK detail, so the wizard log shows npm rows with the same timing brew rows get (npm batch installs have no per-package timing to report, so those rows stay clean rather than showing invented numbers). --- internal/npm/npm.go | 3 ++- internal/npm/streaming.go | 9 ++++++--- internal/npm/streaming_test.go | 6 +++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/internal/npm/npm.go b/internal/npm/npm.go index 0b489fc..3fd4ad0 100644 --- a/internal/npm/npm.go +++ b/internal/npm/npm.go @@ -250,8 +250,9 @@ func installSequentialContext(ctx context.Context, toInstall []string) (failed [ for _, pkg := range remaining { npmStepStart(bar, pkg) + start := time.Now() errMsg := installNpmPackageWithRetryContext(ctx, pkg) - npmStepDone(bar, pkg, errMsg == "", errMsg) + npmStepDone(bar, pkg, errMsg == "", errMsg, ui.FormatDuration(time.Since(start))) if errMsg != "" { failed = append(failed, pkg) } diff --git a/internal/npm/streaming.go b/internal/npm/streaming.go index 52a40c1..3ac9fa0 100644 --- a/internal/npm/streaming.go +++ b/internal/npm/streaming.go @@ -43,11 +43,14 @@ func npmStepStart(bar *ui.StickyProgress, name string) { } // npmStepDone reports the result of a single npm package install, preserving -// the exact console output when not streaming. -func npmStepDone(bar *ui.StickyProgress, name string, ok bool, errMsg string) { +// the exact console output when not streaming. duration is the measured +// install time (e.g. "2.1s") carried as the success Detail so streaming +// renderers show npm rows with the same timing brew rows get; empty means +// no per-package timing is available. +func npmStepDone(bar *ui.StickyProgress, name string, ok bool, errMsg, duration string) { if streaming() { if ok { - progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepOK}) + progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepOK, Detail: duration}) } else { progressSink.Emit(progress.Event{Phase: progress.PhaseNpm, Name: name, Status: progress.StepFail, Detail: errMsg}) } diff --git a/internal/npm/streaming_test.go b/internal/npm/streaming_test.go index fa73ec7..0d2e803 100644 --- a/internal/npm/streaming_test.go +++ b/internal/npm/streaming_test.go @@ -26,12 +26,12 @@ func TestNpmStepHelpersEmitWhenStreaming(t *testing.T) { assert.NotPanics(t, func() { npmStepStart(nil, "typescript") - npmStepDone(nil, "typescript", true, "") - npmStepDone(nil, "eslint", false, "E404") + npmStepDone(nil, "typescript", true, "", "2.1s") + npmStepDone(nil, "eslint", false, "E404", "0.3s") }) require.Len(t, got, 3) assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "typescript", Status: progress.StepStart, Command: "npm install -g typescript"}, got[0]) - assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "typescript", Status: progress.StepOK}, got[1]) + assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "typescript", Status: progress.StepOK, Detail: "2.1s"}, got[1]) assert.Equal(t, progress.Event{Phase: progress.PhaseNpm, Name: "eslint", Status: progress.StepFail, Detail: "E404"}, got[2]) } From 6b1adfa4088bda40319f9ebbb907dc9ff7ad8172 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Thu, 16 Jul 2026 01:13:51 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(tui):=20wizard=20deep-polish=20?= =?UTF-8?q?=E2=80=94=20online=20search,=20preset=20entry,=20summary,=20sma?= =?UTF-8?q?ll=20terminals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the UX gaps found in the v0.63 comparison: - select screen regains online search (lost in the Redesign v5 rewrite): filtering fires a debounced openboot.dev lookup; hits are deduped against the catalog, badge-marked, and picks are homed in a synthetic 'online' sidebar category so they survive clearing the filter. PlanFromSelection gains an online parameter so their type info reaches categorization. - 'install -p ' (and OPENBOOT_PRESET) on a TTY now enters the full wizard with the loadout preselected on the select screen, instead of the linear pre-flight + pipeline-screen stitch. Unknown presets keep the linear path and its clear error. - completion appends a summary to the log tail — counts plus the names of failed packages, which had scrolled away by the DONE screen. - elapsed times render as 3m24s instead of raw seconds everywhere. - terminals below 60x15 get a resize hint instead of a broken frame. - the hover background derives its escape from the active lipgloss colour profile, so it downsamples on 256/16-colour terminals and disappears cleanly without colour support. --- CLAUDE.md | 2 +- internal/cli/install.go | 28 ++- internal/cli/wizard_routing_test.go | 27 +++ internal/installer/plan.go | 9 +- internal/installer/plan_selection_test.go | 16 +- internal/ui/tui/wizard/boot.go | 9 + internal/ui/tui/wizard/confirm.go | 2 +- internal/ui/tui/wizard/install.go | 39 +++- internal/ui/tui/wizard/select.go | 222 ++++++++++++++++++++-- internal/ui/tui/wizard/status.go | 6 +- internal/ui/tui/wizard/styles.go | 32 +++- internal/ui/tui/wizard/wizard.go | 47 ++++- internal/ui/tui/wizard/wizard_test.go | 137 ++++++++++++- 13 files changed, 533 insertions(+), 43 deletions(-) create mode 100644 internal/cli/wizard_routing_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 51ff8d6..7b6418a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ 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); explicit sources (`-p`, `--from`, `-u`, sync), `--silent`, and `--dry-run` use the linear flow. +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. Slug/`--from`/`-u`/sync installs keep their linear pre-flight prompts but stream the apply through the wizard's live install screen (`RunPipeline`). `--silent`, `--dry-run`, `--update`, and non-TTY runs use the linear flow. 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 eb8f4a2..2a29203 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -168,15 +168,33 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / return err } -// shouldLaunchWizard reports whether this run is a bare interactive -// `openboot install` on a TTY — the case the full-screen wizard (boot probe → -// select → live install) owns. Explicit sources (-p, --from, -u, sync), -// --silent, --dry-run, and --update keep their existing flows. +// shouldLaunchWizard reports whether this run gets the full-screen wizard +// (boot probe → select → live install) on a TTY: a bare `openboot install`, +// or a valid preset (-p / OPENBOOT_PRESET / positional), which enters the +// wizard with that loadout preselected on the select screen. Remote sources +// (--from, -u, slug, sync), --silent, --dry-run, and --update keep their +// existing flows. func shouldLaunchWizard(src *installSource) bool { - return src.kind == sourceNone && !installCfg.Silent && !installCfg.DryRun && + return wizardSource(src) && !installCfg.Silent && !installCfg.DryRun && !installCfg.Update && system.HasTTY() } +// wizardSource is the source-kind half of the wizard-routing decision, split +// from the TTY/mode checks so it's testable without a terminal. +func wizardSource(src *installSource) bool { + switch src.kind { + case sourceNone: + return true + case sourcePreset: + // An unknown preset must keep the linear path, which rejects it with a + // clear error instead of silently opening an empty wizard. + _, ok := config.GetPreset(installCfg.Preset) + return ok + default: + return false + } +} + // 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 diff --git a/internal/cli/wizard_routing_test.go b/internal/cli/wizard_routing_test.go new file mode 100644 index 0000000..b0df77d --- /dev/null +++ b/internal/cli/wizard_routing_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// wizardSource is the source-kind half of shouldLaunchWizard, split out so the +// routing is testable without a TTY: bare installs and valid presets get the +// full wizard; unknown presets and remote sources keep the linear path. +func TestWizardSource(t *testing.T) { + oldPreset := installCfg.Preset + defer func() { installCfg.Preset = oldPreset }() + + assert.True(t, wizardSource(&installSource{kind: sourceNone}), "bare install") + + installCfg.Preset = "developer" + assert.True(t, wizardSource(&installSource{kind: sourcePreset}), "valid preset") + + installCfg.Preset = "not-a-preset" + assert.False(t, wizardSource(&installSource{kind: sourcePreset}), "unknown preset keeps the linear error path") + + assert.False(t, wizardSource(&installSource{kind: sourceCloud}), "cloud config") + assert.False(t, wizardSource(&installSource{kind: sourceSyncSource}), "sync source") + assert.False(t, wizardSource(&installSource{kind: sourceFile}), "local file") +} diff --git a/internal/installer/plan.go b/internal/installer/plan.go index d291f3f..4fba52b 100644 --- a/internal/installer/plan.go +++ b/internal/installer/plan.go @@ -309,7 +309,9 @@ func planMacOSDecision(opts *config.InstallOptions) ([]macos.Preference, error) } // PlanFromSelection builds a ready-to-Apply InstallPlan from an explicit -// package selection gathered by the install TUI. It applies system-config +// package selection gathered by the install TUI. online carries packages the +// wizard picked from openboot.dev search — they aren't in the local catalog, +// so categorization needs their type info. It applies system-config // defaults (existing git identity, oh-my-zsh, dotfiles, macOS prefs) without // any interactive prompts — all interaction already happened in the wizard. // @@ -317,8 +319,8 @@ func planMacOSDecision(opts *config.InstallOptions) ([]macos.Preference, error) // absent the git step is skipped rather than prompting, since the TUI has no // name/email screen. CLI overrides (--packages-only, --shell/--macos/--dotfiles // skip) are still honored via opts. -func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool) InstallPlan { - st := &config.InstallState{SelectedPkgs: selected} +func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool, online []config.Package) InstallPlan { + st := &config.InstallState{SelectedPkgs: selected, OnlinePkgs: online} plan := InstallPlan{ Version: opts.Version, @@ -327,6 +329,7 @@ func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool) In PackagesOnly: opts.PackagesOnly, AllowPostInstall: opts.AllowPostInstall, SelectedPkgs: selected, + OnlinePkgs: online, } cats := categorizeSelectedPackages(opts, st) diff --git a/internal/installer/plan_selection_test.go b/internal/installer/plan_selection_test.go index cd1ac6f..3707dcb 100644 --- a/internal/installer/plan_selection_test.go +++ b/internal/installer/plan_selection_test.go @@ -14,7 +14,7 @@ func TestPlanFromSelectionDefaults(t *testing.T) { sel := config.GetPackagesForPreset("developer") require.NotEmpty(t, sel) - plan := PlanFromSelection(opts, sel) + plan := PlanFromSelection(opts, sel, nil) assert.Equal(t, "1.0.0", plan.Version) assert.Positive(t, len(plan.Formulae)+len(plan.Casks)+len(plan.Npm), "packages categorized") @@ -35,7 +35,7 @@ func TestPlanFromSelectionDefaults(t *testing.T) { func TestPlanFromSelectionPackagesOnly(t *testing.T) { opts := &config.InstallOptions{PackagesOnly: true} - plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil) assert.True(t, plan.PackagesOnly) assert.False(t, plan.InstallOhMyZsh) @@ -46,9 +46,19 @@ func TestPlanFromSelectionPackagesOnly(t *testing.T) { func TestPlanFromSelectionSkipFlags(t *testing.T) { opts := &config.InstallOptions{Shell: "skip", Dotfiles: "skip", Macos: "skip"} - plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + plan := PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil) assert.False(t, plan.InstallOhMyZsh) assert.Empty(t, plan.DotfilesURL) assert.Empty(t, plan.MacOSPrefs) } + +// Online picks come from openboot.dev search — not in the local catalog, so +// their type info must flow through OnlinePkgs into categorization. +func TestPlanFromSelectionIncludesOnlinePicks(t *testing.T) { + opts := &config.InstallOptions{PackagesOnly: true} + online := []config.Package{{Name: "web-only-tool", IsNpm: true}} + plan := PlanFromSelection(opts, map[string]bool{"web-only-tool": true}, online) + assert.Contains(t, plan.Npm, "web-only-tool") + assert.Equal(t, online, plan.OnlinePkgs) +} diff --git a/internal/ui/tui/wizard/boot.go b/internal/ui/tui/wizard/boot.go index c84aac8..2e2adaf 100644 --- a/internal/ui/tui/wizard/boot.go +++ b/internal/ui/tui/wizard/boot.go @@ -101,6 +101,15 @@ func (m Model) onProbeDone(msg probeDoneMsg) (tea.Model, tea.Cmd) { if m.probeIdx < len(m.probes) { return m, m.runProbe(m.probeIdx) } + // A preset given on the CLI (-p / OPENBOOT_PRESET) already answers the + // loadout question — skip straight to the select screen with it applied, + // so the flag means "start from this loadout, review, install" instead of + // bypassing the wizard entirely. + if m.opts.Preset != "" { + if _, ok := config.GetPreset(m.opts.Preset); ok { + return m.enterSelect(config.GetPackagesForPreset(m.opts.Preset)) + } + } return m, nil // probing complete; wait for a loadout choice } diff --git a/internal/ui/tui/wizard/confirm.go b/internal/ui/tui/wizard/confirm.go index a77a317..9b13bf1 100644 --- a/internal/ui/tui/wizard/confirm.go +++ b/internal/ui/tui/wizard/confirm.go @@ -40,7 +40,7 @@ func (m Model) confirmRows() []confirmRow { // enterConfirm computes the plan preview and shows the confirm screen. func (m Model) enterConfirm() (tea.Model, tea.Cmd) { - m.preview = installer.PlanFromSelection(m.opts, m.selected) + m.preview = installer.PlanFromSelection(m.opts, m.selected, m.selectedOnlinePkgs()) m.confShell, m.confDotfiles, m.confPrefs = true, true, true m.confCur, m.hoverRow = 0, -1 m.screen = scrConfirm diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go index 5b6bb42..e6c6b9f 100644 --- a/internal/ui/tui/wizard/install.go +++ b/internal/ui/tui/wizard/install.go @@ -65,7 +65,7 @@ func (r chanReporter) Muted(s string) { r.ch <- reporterMsg{kind: rMuted, text // ── starting the install ── func (m Model) startInstall() (tea.Model, tea.Cmd) { - plan := installer.PlanFromSelection(m.opts, m.selected) + plan := installer.PlanFromSelection(m.opts, m.selected, m.selectedOnlinePkgs()) // 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. @@ -265,11 +265,34 @@ func (m Model) onInstallEvent(msg tea.Msg) (tea.Model, tea.Cmd) { 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 { @@ -305,6 +328,9 @@ func (m *Model) applyProgressEvent(ev progress.Event) { 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}) } } @@ -441,6 +467,15 @@ 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) { @@ -561,7 +596,7 @@ 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 · %ds", pkgN, m.elapsed())) + " " + 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") diff --git a/internal/ui/tui/wizard/select.go b/internal/ui/tui/wizard/select.go index 37c4b2d..00df46a 100644 --- a/internal/ui/tui/wizard/select.go +++ b/internal/ui/tui/wizard/select.go @@ -3,12 +3,39 @@ package wizard import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/openbootdotdev/openboot/internal/config" + "github.com/openbootdotdev/openboot/internal/search" ) +// searchOnline is a seam so tests can stub the openboot.dev search client. +var searchOnline = search.SearchOnline + +const ( + // onlineCatName is the synthetic sidebar category that holds packages + // picked from openboot.dev search results (they aren't in the local + // catalog, so without a home they'd vanish when the filter clears). + onlineCatName = "online" + searchDebounce = 450 * time.Millisecond + searchMinRunes = 2 // don't hit the network for 1-char queries + onlineMaxHits = 20 +) + +// searchTickMsg fires after the debounce delay; stale generations are dropped. +type searchTickMsg struct { + seq int + query string +} + +// searchDoneMsg carries the online results for generation seq. +type searchDoneMsg struct { + seq int + results []config.Package +} + // ── selection helpers ── func (m Model) isInstalled(name string) bool { return m.installed[name] } @@ -43,7 +70,8 @@ func (m Model) skippedCount() int { return m.selCount() - m.toInstallCount() } func (m Model) estMin() int { return estMinutes(m.toInstallCount()) } // pool returns the packages shown in the right pane: the active category, or — -// when a query is present — a substring match across the whole catalog. +// when a query is present — a substring match across the whole catalog plus +// any online hits for the query (already deduped against the catalog). func (m Model) pool() []config.Package { q := strings.TrimSpace(strings.ToLower(m.query)) if q != "" { @@ -55,7 +83,7 @@ func (m Model) pool() []config.Package { } } } - return out + return append(out, m.onlineResults...) } if m.catCur >= 0 && m.catCur < len(m.cats) { return m.cats[m.catCur].Packages @@ -63,6 +91,154 @@ func (m Model) pool() []config.Package { return nil } +// ── online search (debounced openboot.dev lookup while filtering) ── + +// queryChanged resets list position and (re)arms the search debounce for the +// current query. Every call bumps the generation, so in-flight lookups for a +// stale query can never land. +func (m *Model) queryChanged() tea.Cmd { + m.rowCur, m.scroll = 0, 0 + m.onlineResults, m.onlineBusy = nil, false + m.searchSeq++ + q := strings.TrimSpace(m.query) + if len([]rune(q)) < searchMinRunes { + return nil + } + seq := m.searchSeq + return tea.Tick(searchDebounce, func(time.Time) tea.Msg { + return searchTickMsg{seq: seq, query: q} + }) +} + +// clearOnline drops transient search state and invalidates in-flight lookups. +func (m *Model) clearOnline() { + m.onlineResults, m.onlineBusy = nil, false + m.searchSeq++ +} + +func (m Model) onSearchTick(msg searchTickMsg) (tea.Model, tea.Cmd) { + if msg.seq != m.searchSeq || !m.typing || strings.TrimSpace(m.query) != msg.query { + return m, nil // typed past it — a newer generation is armed + } + m.onlineBusy = true + seq := msg.seq + return m, func() tea.Msg { + res, err := searchOnline(msg.query) + if err != nil { + res = nil // offline / API down: quietly show local hits only + } + return searchDoneMsg{seq: seq, results: res} + } +} + +func (m Model) onSearchDone(msg searchDoneMsg) (tea.Model, tea.Cmd) { + if msg.seq != m.searchSeq { + return m, nil + } + m.onlineBusy = false + m.onlineResults = m.filterOnline(msg.results) + for _, p := range m.onlineResults { + m.onlineKnown[p.Name] = true + } + return m, nil +} + +// filterOnline dedupes online hits against the catalog (including earlier +// online picks) and against themselves, and caps the list. +func (m Model) filterOnline(in []config.Package) []config.Package { + seen := map[string]bool{} + for _, c := range m.cats { + for _, p := range c.Packages { + seen[p.Name] = true + } + } + var out []config.Package + for _, p := range in { + if p.Name == "" || seen[p.Name] { + continue + } + seen[p.Name] = true + out = append(out, p) + if len(out) >= onlineMaxHits { + break + } + } + return out +} + +// togglePkg flips selection for p. Online picks are materialised into the +// synthetic "online" sidebar category so they stay visible (and deselectable) +// after the filter clears — they have no home in the local catalog. +func (m *Model) togglePkg(p config.Package) { + if m.isInstalled(p.Name) { + return + } + m.toggle(p.Name) + if !m.onlineKnown[p.Name] { + return + } + if m.selected[p.Name] { + m.addOnlinePick(p) + } else { + m.removeOnlinePick(p.Name) + } +} + +func (m *Model) addOnlinePick(p config.Package) { + for i := range m.cats { + if m.cats[i].Name != onlineCatName { + continue + } + for _, q := range m.cats[i].Packages { + if q.Name == p.Name { + return + } + } + m.cats[i].Packages = append(m.cats[i].Packages, p) + return + } + m.cats = append(m.cats, config.Category{Name: onlineCatName, Packages: []config.Package{p}}) +} + +func (m *Model) removeOnlinePick(name string) { + for i := range m.cats { + if m.cats[i].Name != onlineCatName { + continue + } + kept := m.cats[i].Packages[:0] + for _, q := range m.cats[i].Packages { + if q.Name != name { + kept = append(kept, q) + } + } + m.cats[i].Packages = kept + if len(kept) == 0 { + m.cats = append(m.cats[:i], m.cats[i+1:]...) + if m.catCur >= len(m.cats) { + m.catCur = len(m.cats) - 1 + } + } + return + } +} + +// selectedOnlinePkgs returns the online picks that are currently selected — +// the extra packages the plan needs beyond the catalog selection. +func (m Model) selectedOnlinePkgs() []config.Package { + var out []config.Package + for _, c := range m.cats { + if c.Name != onlineCatName { + continue + } + for _, p := range c.Packages { + if m.selected[p.Name] { + out = append(out, p) + } + } + } + return out +} + // selVisible is the number of package rows the list area can show. It must match // selectList's own count (body height − search row − blank) exactly, or the // scroll clamp and the render disagree: leaving blank rows at the bottom and @@ -98,23 +274,23 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy s := msg.String() if m.typing { + var cmd tea.Cmd switch s { case "esc": m.query, m.typing, m.rowCur, m.scroll = "", false, 0, 0 + m.clearOnline() case "enter": if strings.TrimSpace(m.query) != "" && len(pool) > 0 { - p := pool[clamp(m.rowCur, 0, last)] - if !m.isInstalled(p.Name) { - m.toggle(p.Name) - } + m.togglePkg(pool[clamp(m.rowCur, 0, last)]) m.query, m.typing, m.rowCur, m.scroll = "", false, 0, 0 + m.clearOnline() } else { return m.tryInstall() } case "backspace": if len(m.query) > 0 { m.query = trimLast(m.query) - m.rowCur, m.scroll = 0, 0 + cmd = m.queryChanged() } case "up": if m.rowCur > 0 { @@ -129,10 +305,10 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy // (bubbletea coalesces pasted/fast text into one message). if msg.Type == tea.KeyRunes || s == " " { m.query += s - m.rowCur, m.scroll = 0, 0 + cmd = m.queryChanged() } } - return m.clampSelScroll(), nil + return m.clampSelScroll(), cmd } switch s { @@ -143,8 +319,10 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy // Filtering searches across categories, so it's a list operation — // pull focus to the list so ↑↓ behaves predictably after it clears. m.typing, m.query, m.rowCur, m.scroll, m.selFocus = true, "", 0, 0, focusList + m.clearOnline() case "esc": m.query, m.rowCur, m.scroll = "", 0, 0 + m.clearOnline() case "left", "h": m.selFocus = focusCats case "right", "l": @@ -161,10 +339,7 @@ func (m Model) updateSelect(msg tea.KeyMsg) (tea.Model, tea.Cmd) { //nolint:gocy m = m.selMoveDown() case " ": if len(pool) > 0 { - p := pool[clamp(m.rowCur, 0, last)] - if !m.isInstalled(p.Name) { - m.toggle(p.Name) - } + m.togglePkg(pool[clamp(m.rowCur, 0, last)]) } case "a": for _, p := range pool { @@ -257,12 +432,13 @@ func (m Model) updateSelectMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.catCur, m.query, m.typing = idx, "", false m.selFocus = focusCats m.rowCur, m.scroll = 0, 0 + m.clearOnline() case hitPkg: m.rowCur = idx m.selFocus = focusList pool := m.pool() - if idx < len(pool) && !m.isInstalled(pool[idx].Name) { - m.toggle(pool[idx].Name) + if idx < len(pool) { + m.togglePkg(pool[idx]) } case hitNone: // click landed on chrome or blank space — nothing to do @@ -419,7 +595,14 @@ func (m Model) selectList(w, h int) []string { } match := "" if strings.TrimSpace(m.query) != "" { - match = fg(cDim3).Render(fmt.Sprintf("%d hits", len(pool))) + hits := fmt.Sprintf("%d hits", len(pool)) + switch { + case m.onlineBusy: + hits += " · " + m.spinner() + " openboot.dev" + case len(m.onlineResults) > 0: + hits += fmt.Sprintf(" · %d from openboot.dev", len(m.onlineResults)) + } + match = fg(cDim3).Render(hits) } rows = append(rows, bar(" "+icon+" "+queryText, match+" ", w)) rows = append(rows, "") @@ -472,8 +655,13 @@ func (m Model) renderRow(p config.Package, cursor bool, w int) string { } tail := fg(cDim3).Render(pkgType(p)) - if installed { + switch { + case installed: tail = fg(cInstalled).Render("installed") + case m.onlineKnown[p.Name]: + // Cyan type badge marks a row sourced from openboot.dev search rather + // than the local catalog. + tail = fg(cInfo).Render(pkgType(p)) } name := padTo(nameStyle.Render(p.Name), 20) diff --git a/internal/ui/tui/wizard/status.go b/internal/ui/tui/wizard/status.go index 2333017..325ecdd 100644 --- a/internal/ui/tui/wizard/status.go +++ b/internal/ui/tui/wizard/status.go @@ -31,13 +31,13 @@ func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right s default: // scrInstall if m.done { return "DONE", cAccent, "r replay from boot · q quit", - fmt.Sprintf("%d steps · %ds", m.totalSteps(), m.elapsed()) + 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 · %ds", m.completedSteps(), m.totalSteps(), m.elapsed()) + 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 · %ds", m.completedSteps(), m.totalSteps(), m.elapsed()) + 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 d252353..1e3ebe1 100644 --- a/internal/ui/tui/wizard/styles.go +++ b/internal/ui/tui/wizard/styles.go @@ -2,6 +2,7 @@ package wizard import ( "strings" + "sync" "github.com/charmbracelet/lipgloss" ) @@ -39,12 +40,33 @@ func fg(c lipgloss.Color) lipgloss.Style { return lipgloss.NewStyle().Foreground(c) } -// hoverBg paints a hard ANSI true-color 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. cHover = #3d3d4a → rgb(61,61,74). +// 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. func hoverBg(s string) string { - const bg = "\x1b[48;2;61;61;74m" + 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" } diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go index 3f8d3ee..f9cfc9d 100644 --- a/internal/ui/tui/wizard/wizard.go +++ b/internal/ui/tui/wizard/wizard.go @@ -3,8 +3,10 @@ // live pipeline install, 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. Non-interactive -// paths (--silent, --dry-run, presets, --from, -u, sync) never reach here. +// selector, per-step confirms) and the linear Apply output. Preset installs +// (-p) enter it with the loadout preselected. Non-interactive paths (--silent, +// --dry-run, --from, -u, sync) never reach the full wizard; slug/sync installs +// reuse only the live install screen via RunPipeline. package wizard import ( @@ -79,6 +81,12 @@ type Model struct { hoverRow int // mouse hover index in pool(), -1 when not on a package row selected map[string]bool + // ── select: online search (packages beyond the local catalog) ── + searchSeq int // debounce generation; stale ticks/results are dropped + onlineBusy bool // a search request is in flight + onlineResults []config.Package // current query's online hits (deduped vs catalog) + onlineKnown map[string]bool // names sourced from openboot.dev, for the row badge + // ── git identity (captured only when none is configured) ── gitName string gitEmail string @@ -103,6 +111,7 @@ type Model struct { 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 @@ -124,6 +133,7 @@ func New(version string, opts *config.InstallOptions) Model { 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{}, @@ -210,6 +220,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case probeDoneMsg: return m.onProbeDone(msg) + case searchTickMsg: + return m.onSearchTick(msg) + + case searchDoneMsg: + return m.onSearchDone(msg) + case evMsg, reporterMsg, installDoneMsg: return m.onInstallEvent(msg) } @@ -239,10 +255,21 @@ func (m Model) spinner() string { // ── View / chrome ────────────────────────────────────────────────────────── +// minTermW/minTermH are the smallest terminal the layout renders legibly in: +// below this the two-pane screens truncate into garbage, so show a resize +// hint instead of a broken frame. Keys (q / ctrl+c) still work. +const ( + minTermW = 60 + minTermH = 15 +) + func (m Model) View() string { if m.width == 0 || m.height == 0 { return "" } + if m.width < minTermW || m.height < minTermH { + return m.smallTermView() + } bodyH := m.height - 2 if bodyH < 1 { bodyH = 1 @@ -265,6 +292,22 @@ func (m Model) View() string { return m.titleBar() + "\n" + fitBlock(body, m.width, bodyH) + "\n" + m.statusBar() } +// smallTermView replaces the whole frame when the terminal is smaller than +// the layout can survive. Plain short lines so it stays legible at any size; +// ctrl+c (handled globally) still quits. +func (m Model) smallTermView() string { + lines := []string{ + "", + " " + fg(cAccent).Render("▲") + " " + fg(cTextHi).Render("openboot"), + "", + " " + fg(cWarn).Render(fmt.Sprintf("terminal too small — %d×%d", m.width, m.height)), + " " + fg(cMuted).Render(fmt.Sprintf("resize to at least %d×%d to continue", minTermW, minTermH)), + "", + " " + fg(cDim2).Render("ctrl+c quit"), + } + return fitBlock(strings.Join(lines, "\n"), m.width, m.height) +} + // inBody reports whether screen row y is inside the rendered body (rows // 1..height-2; row 0 is the title bar and height-1 the status bar). Mouse // hit-tests guard on it so a click on the chrome never maps to a body row — diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index 3b24721..180decc 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -636,7 +636,7 @@ func TestInstallGoroutineStreamsToDone(t *testing.T) { m := New("1", opts) m.screen = scrInstall - plan := installer.PlanFromSelection(opts, config.GetPackagesForPreset("minimal")) + plan := installer.PlanFromSelection(opts, config.GetPackagesForPreset("minimal"), nil) plan.Silent = true m.plan = plan m.phases = buildPhases(plan) @@ -777,3 +777,138 @@ func TestPhasesForPlan(t *testing.T) { 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 ── + +func TestSmallTerminalShowsResizeHint(t *testing.T) { + m := sized(48, 12) + v := m.View() + assert.Contains(t, v, "terminal too small") + assert.Contains(t, v, "resize to at least 60×15") + + // Growing the window restores the real frame. + m = send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) + assert.NotContains(t, m.View(), "terminal too small") +} + +func TestPresetOptionAutoAdvancesToSelect(t *testing.T) { + m := New("1.4.0", &config.InstallOptions{Version: "1.4.0", Preset: "developer"}) + m = send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) + m = finishProbes(m) + require.Equal(t, scrSelect, m.screen, "-p skips the loadout question") + assert.Equal(t, config.GetPackagesForPreset("developer"), m.selected) +} + +func TestUnknownPresetStaysOnBoot(t *testing.T) { + m := New("1.4.0", &config.InstallOptions{Version: "1.4.0", Preset: "bogus"}) + m = send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) + m = finishProbes(m) + assert.Equal(t, scrBoot, m.screen) +} + +func TestOnlineSearchFindsTogglesAndSurvivesFilterClear(t *testing.T) { + restore := searchOnline + searchOnline = func(string) ([]config.Package, error) { + return []config.Package{ + {Name: "web-only-tool", Description: "only on openboot.dev", IsNpm: true}, + {Name: "curl", Description: "already in the catalog"}, // must be deduped + }, nil + } + defer func() { searchOnline = restore }() + + m := finishProbes(sized(96, 30)) + m = send(m, key("c")) + m = send(m, key("/")) + for _, r := range "web-only" { + m = send(m, key(string(r))) + } + seq := m.searchSeq + + // A stale generation must be ignored outright. + m = send(m, searchDoneMsg{seq: seq - 1, results: []config.Package{{Name: "stale"}}}) + require.Empty(t, m.onlineResults) + + // The debounce tick for the live generation arms the lookup… + next, cmd := m.Update(searchTickMsg{seq: seq, query: strings.TrimSpace(m.query)}) + m = next.(Model) + require.True(t, m.onlineBusy) + require.NotNil(t, cmd) + // …whose result lands as a searchDoneMsg (stub is synchronous). + m = send(m, cmd().(searchDoneMsg)) + require.False(t, m.onlineBusy) + require.Len(t, m.onlineResults, 1, "catalog dupes are filtered out") + + // The online hit is in the pool; enter toggles the cursor row, clears the + // filter, and the pick survives in the synthetic online category. + pool := m.pool() + idx := -1 + for i, p := range pool { + if p.Name == "web-only-tool" { + idx = i + } + } + require.GreaterOrEqual(t, idx, 0, "online hit joins the filtered pool") + m.rowCur = idx + m = send(m, key("enter")) + assert.True(t, m.selected["web-only-tool"]) + assert.Empty(t, m.query, "enter-toggle clears the filter") + + foundCat := false + for _, c := range m.cats { + if c.Name == onlineCatName { + foundCat = true + assert.Len(t, c.Packages, 1) + } + } + require.True(t, foundCat, "online pick is homed in the sidebar category") + + online := m.selectedOnlinePkgs() + require.Len(t, online, 1) + assert.True(t, online[0].IsNpm, "type info survives for categorization") + + // Deselecting removes the pick and the now-empty category. + m.catCur = len(m.cats) - 1 + m.rowCur = 0 + m = send(m, key("space")) + assert.False(t, m.selected["web-only-tool"]) + for _, c := range m.cats { + assert.NotEqual(t, onlineCatName, c.Name, "empty online category is dropped") + } + 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)) + } +} From aac5be1ee7796f8cca1524ced0a81d5d2321e680 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Thu, 16 Jul 2026 01:21:23 +0800 Subject: [PATCH 3/4] feat(tui): run slug/--from/-u installs through the full wizard (config mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote-config installs on a TTY previously got a stitched experience: linear 3-way prompt + the old full-screen customizer, then a jump into the wizard's live install screen. They now enter the wizard proper via RunForConfig: boot probe → select (the config's own packages as sidebar categories, all preselected, installed tools marked) → review plan → live install. - installer.PlanForRemoteSelection builds the plan from the config filtered to the wizard selection, plus openboot.dev search picks; everything declarative (git, shell, dotfiles, prefs, dock, login items, post-install) flows through planFromRemoteConfig unchanged. - config mode never interposes the git screen — the git step already keeps an existing config and no-ops when absent, matching the old slug pipeline behavior; the review screen says so instead of rendering an empty identity. - post-install can't host its confirm inside the alt-screen: the review screen surfaces it as an informational row, the streamed apply strips it, and the CLI runs it after teardown from the returned plan (mirroring the pipeline path's R1 fix). - sync-source installs are unchanged (diff pre-flight + RunPipeline); --pick, --silent, --dry-run, --update and non-TTY keep their flows. - promptCustomizeAndApply + RunConfigCustomizer remain for the dry-run/update/non-TTY prompt paths and the sync customizer. --- CLAUDE.md | 2 +- internal/cli/install.go | 35 +++++++ internal/installer/plan.go | 51 ++++++++++ internal/installer/plan_selection_test.go | 30 ++++++ internal/ui/tui/wizard/boot.go | 5 + internal/ui/tui/wizard/config_mode_test.go | 105 +++++++++++++++++++++ internal/ui/tui/wizard/confirm.go | 38 ++++++-- internal/ui/tui/wizard/git.go | 6 ++ internal/ui/tui/wizard/install.go | 20 +++- internal/ui/tui/wizard/select.go | 21 +++++ internal/ui/tui/wizard/status.go | 8 +- internal/ui/tui/wizard/wizard.go | 55 ++++++++++- 12 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 internal/ui/tui/wizard/config_mode_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 7b6418a..bdf9a64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ 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. Slug/`--from`/`-u`/sync installs keep their linear pre-flight prompts 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`. 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. 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 2a29203..f4f960c 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -136,6 +136,12 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / return perr } installCfg.RemoteConfig = rc + } else if !installCfg.Silent && !installCfg.DryRun && !installCfg.Update && system.HasTTY() { + // The full-screen config wizard owns the whole interactive flow + // (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) } else if !installCfg.Silent && (!installCfg.DryRun || system.HasTTY()) { rc, proceed, err := promptCustomizeAndApply(installCfg.RemoteConfig) if err != nil { @@ -274,6 +280,35 @@ func runInstallWizard() error { return nil } +// 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") + } + 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) + } + return err +} + // ── Source resolution ───────────────────────────────────────────────────────── type sourceKind int diff --git a/internal/installer/plan.go b/internal/installer/plan.go index 4fba52b..6aec411 100644 --- a/internal/installer/plan.go +++ b/internal/installer/plan.go @@ -369,6 +369,57 @@ func PlanFromSelection(opts *config.InstallOptions, selected map[string]bool, on return plan } +// PlanForRemoteSelection builds a ready-to-Apply InstallPlan from a remote +// config filtered to the wizard's package selection, plus any openboot.dev +// search picks made on the select screen. It is the config-mode counterpart +// of PlanFromSelection: everything non-package (git, dotfiles, shell, macOS +// prefs, dock, login items, post-install) comes from the remote config via +// planFromRemoteConfig, exactly like the declarative slug path — no prompts. +func PlanForRemoteSelection(opts *config.InstallOptions, rc *config.RemoteConfig, selected map[string]bool, online []config.Package) InstallPlan { + f := *rc + f.Packages = filterEntriesBySelection(rc.Packages, selected) + f.Casks = filterEntriesBySelection(rc.Casks, selected) + f.Npm = filterEntriesBySelection(rc.Npm, selected) + + plan := InstallPlan{ + Version: opts.Version, + DryRun: opts.DryRun, + Silent: opts.Silent, + PackagesOnly: opts.PackagesOnly, + AllowPostInstall: opts.AllowPostInstall, + RemoteConfig: &f, + } + st := &config.InstallState{RemoteConfig: &f} + planFromRemoteConfig(opts, st, &plan) + + for _, p := range online { + if !selected[p.Name] || plan.SelectedPkgs[p.Name] { + continue + } + switch { + case p.IsNpm: + plan.Npm = append(plan.Npm, p.Name) + case p.IsCask: + plan.Casks = append(plan.Casks, p.Name) + default: + plan.Formulae = append(plan.Formulae, p.Name) + } + plan.SelectedPkgs[p.Name] = true + plan.OnlinePkgs = append(plan.OnlinePkgs, p) + } + return plan +} + +func filterEntriesBySelection(in config.PackageEntryList, selected map[string]bool) config.PackageEntryList { + out := make(config.PackageEntryList, 0, len(in)) + for _, e := range in { + if selected[e.Name] { + out = append(out, e) + } + } + return out +} + // PlanFromSnapshot builds an InstallPlan from snapshot state without any interactive // prompts. All decisions are derived from st.Snapshot* fields and opts. func PlanFromSnapshot(opts *config.InstallOptions, st *config.InstallState) InstallPlan { diff --git a/internal/installer/plan_selection_test.go b/internal/installer/plan_selection_test.go index 3707dcb..892e94d 100644 --- a/internal/installer/plan_selection_test.go +++ b/internal/installer/plan_selection_test.go @@ -62,3 +62,33 @@ func TestPlanFromSelectionIncludesOnlinePicks(t *testing.T) { assert.Contains(t, plan.Npm, "web-only-tool") assert.Equal(t, online, plan.OnlinePkgs) } + +// Config-mode plans: the remote config filtered by the wizard's selection, +// with openboot.dev picks appended and all declarative fields carried. +func TestPlanForRemoteSelection(t *testing.T) { + rc := &config.RemoteConfig{ + Packages: config.PackageEntryList{{Name: "cowsay"}, {Name: "fortune"}}, + Casks: config.PackageEntryList{{Name: "warp"}}, + Npm: config.PackageEntryList{{Name: "left-pad"}}, + Taps: []string{"acme/tap"}, + DotfilesRepo: "https://github.com/alice/dotfiles", + Shell: &config.RemoteShellConfig{OhMyZsh: true, Theme: "agnoster", Plugins: []string{"git"}}, + PostInstall: []string{"echo hi"}, + } + sel := map[string]bool{"cowsay": true, "warp": true, "web-x": true} // fortune & left-pad deselected + online := []config.Package{{Name: "web-x", IsNpm: true}} + + plan := PlanForRemoteSelection(&config.InstallOptions{}, rc, sel, online) + + assert.Equal(t, []string{"cowsay"}, plan.Formulae) + assert.Equal(t, []string{"warp"}, plan.Casks) + assert.Equal(t, []string{"web-x"}, plan.Npm, "online pick lands with its npm type") + assert.Equal(t, []string{"acme/tap"}, plan.Taps, "taps ride along untouched") + assert.Equal(t, "https://github.com/alice/dotfiles", plan.DotfilesURL) + assert.True(t, plan.InstallOhMyZsh) + assert.Equal(t, "agnoster", plan.ShellTheme) + assert.Equal(t, []string{"echo hi"}, plan.PostInstall) + assert.True(t, plan.SelectedPkgs["web-x"]) + assert.False(t, plan.SelectedPkgs["fortune"]) + assert.Equal(t, online, plan.OnlinePkgs) +} diff --git a/internal/ui/tui/wizard/boot.go b/internal/ui/tui/wizard/boot.go index 2e2adaf..cedadd6 100644 --- a/internal/ui/tui/wizard/boot.go +++ b/internal/ui/tui/wizard/boot.go @@ -101,6 +101,11 @@ func (m Model) onProbeDone(msg probeDoneMsg) (tea.Model, tea.Cmd) { if m.probeIdx < len(m.probes) { return m, m.runProbe(m.probeIdx) } + // Config mode: the remote config already answers the loadout question — + // go straight to reviewing its (preselected) packages. + if m.rc != nil { + return m.enterSelect(m.selected) + } // A preset given on the CLI (-p / OPENBOOT_PRESET) already answers the // loadout question — skip straight to the select screen with it applied, // so the flag means "start from this loadout, review, install" instead of diff --git a/internal/ui/tui/wizard/config_mode_test.go b/internal/ui/tui/wizard/config_mode_test.go new file mode 100644 index 0000000..9b6294d --- /dev/null +++ b/internal/ui/tui/wizard/config_mode_test.go @@ -0,0 +1,105 @@ +package wizard + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/openbootdotdev/openboot/internal/config" +) + +// Config mode: install / -u / --from / alias runs the full wizard over +// the remote config's own packages instead of the catalog. + +func testRemoteConfig() *config.RemoteConfig { + return &config.RemoteConfig{ + Username: "alice", + Slug: "dev", + Packages: config.PackageEntryList{{Name: "cowsay", Desc: "talking cow"}, {Name: "fortune"}}, + Casks: config.PackageEntryList{{Name: "warp"}}, + Npm: config.PackageEntryList{{Name: "left-pad"}}, + DotfilesRepo: "https://github.com/alice/dotfiles", + Shell: &config.RemoteShellConfig{OhMyZsh: true, Theme: "agnoster"}, + PostInstall: []string{"echo hi"}, + } +} + +func sizedConfig(rc *config.RemoteConfig) Model { + m := NewForConfig("1.4.0", &config.InstallOptions{Version: "1.4.0"}, rc) + return send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) +} + +func TestConfigModeShowsConfigPackagesPreselected(t *testing.T) { + m := sizedConfig(testRemoteConfig()) + require.Len(t, m.cats, 3, "cli tools / apps / npm categories from the config") + assert.Equal(t, 4, m.selCount(), "everything preselected") + assert.Contains(t, m.View(), "openboot install alice/dev", "status bar names the source") + + m = finishProbes(m) + assert.Equal(t, scrSelect, m.screen, "config mode skips the loadout question") + pool := m.pool() + require.NotEmpty(t, pool) + assert.Equal(t, "cowsay", pool[0].Name) +} + +func TestConfigModeSkipsGitScreenAndPlansFromConfig(t *testing.T) { + defer stubGitConfig("", "")() + m := finishProbes(sizedConfig(testRemoteConfig())) + m.installed = map[string]bool{} + + // Deselect "fortune" on the select screen, then continue. + m.rowCur = 1 + m = send(m, key("space")) + require.False(t, m.selected["fortune"]) + m = send(m, key("enter")) + + require.Equal(t, scrConfirm, m.screen, "declarative installs never interpose the git prompt") + assert.NotContains(t, m.preview.Formulae, "fortune", "deselected package leaves the plan") + assert.Contains(t, m.preview.Formulae, "cowsay") + assert.Contains(t, m.preview.Casks, "warp") + assert.Contains(t, m.preview.Npm, "left-pad") + assert.Equal(t, "https://github.com/alice/dotfiles", m.preview.DotfilesURL) + assert.True(t, m.preview.InstallOhMyZsh) + assert.Equal(t, "agnoster", m.preview.ShellTheme) + assert.Equal(t, []string{"echo hi"}, m.preview.PostInstall) +} + +func TestConfigModeConfirmShowsPostInstallAndShiftsHitTest(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) + + assert.Contains(t, m.View(), "runs after the wizard", "post-install surfaced on the review screen") + require.Equal(t, 9, m.confirmHeaderRows(), "post-install line shifts the toggle rows down") + kind, idx := m.confirmHitTest(10) // body row 9 = first toggle row + assert.Equal(t, "row", kind) + assert.Equal(t, 0, idx) + _, miss := m.confirmHitTest(9) + assert.Equal(t, -1, miss, "the info line itself is not clickable") +} + +func TestConfigModeStartInstallDefersPostInstall(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() + + require.Equal(t, scrInstall, m.screen) + require.True(t, m.confirmed) + 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") +} diff --git a/internal/ui/tui/wizard/confirm.go b/internal/ui/tui/wizard/confirm.go index 9b13bf1..3425599 100644 --- a/internal/ui/tui/wizard/confirm.go +++ b/internal/ui/tui/wizard/confirm.go @@ -5,8 +5,6 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" - - "github.com/openbootdotdev/openboot/internal/installer" ) // The confirm screen is the last stop before the engine runs: it shows exactly @@ -40,7 +38,7 @@ func (m Model) confirmRows() []confirmRow { // enterConfirm computes the plan preview and shows the confirm screen. func (m Model) enterConfirm() (tea.Model, tea.Cmd) { - m.preview = installer.PlanFromSelection(m.opts, m.selected, m.selectedOnlinePkgs()) + m.preview = m.buildPlan() m.confShell, m.confDotfiles, m.confPrefs = true, true, true m.confCur, m.hoverRow = 0, -1 m.screen = scrConfirm @@ -97,18 +95,29 @@ func (m Model) confirmBody(_, _ int) string { if skipped > 0 { pkgLine += fg(cDim3).Render(fmt.Sprintf(" · %d already present", skipped)) } - b = append(b, pad+" "+fg(cDim2).Render(padTo("packages", 12))+pkgLine) + b = append(b, pad+" "+fg(cDim2).Render(padTo("packages", 13))+pkgLine) // Git identity (informational). gitVal := m.gitName + " <" + m.gitEmail + ">" if strings.TrimSpace(m.gitName) == "" { - if m.preview.SkipGit { + switch { + case m.preview.SkipGit: gitVal = "not configured — skipped" - } else { + case strings.TrimSpace(m.preview.GitName) == "": + // Config-mode plans carry no identity: the git step keeps an + // existing config and no-ops when there is none. + gitVal = "existing config kept — skipped when absent" + default: gitVal = m.preview.GitName + " <" + m.preview.GitEmail + ">" } } - b = append(b, pad+" "+fg(cDim2).Render(padTo("git", 12))+fg(cMuted).Render(gitVal)) + b = append(b, pad+" "+fg(cDim2).Render(padTo("git", 13))+fg(cMuted).Render(gitVal)) + // Post-install (informational): the script can't run inside the + // alt-screen, so it executes after the wizard, with its own confirm. + if n := len(m.preview.PostInstall); n > 0 { + b = append(b, pad+" "+fg(cDim2).Render(padTo("post-install", 13))+ + fg(cMuted).Render(fmt.Sprintf("%d command(s) · runs after the wizard, asks first", n))) + } b = append(b, "") rows := m.confirmRows() @@ -181,15 +190,24 @@ func (m Model) updateConfirmMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { return m, nil } +// confirmHeaderRows is the number of body rows above the first toggleable row +// (2 blanks + title + subtitle + blank + packages + git + blank = 8, plus one +// for the post-install line when the plan carries a script). +func (m Model) confirmHeaderRows() int { + n := 8 + if len(m.preview.PostInstall) > 0 { + n++ + } + return n +} + // confirmHitTest maps a screen Y coordinate to a confirm-row index. -// Toggleable rows start at body row 8 (title + subtitle + blank + packages -// + git + blank = 7 headers before the first toggle row). func (m Model) confirmHitTest(y int) (string, int) { if !m.inBody(y) { return "", -1 } bodyRow := y - 1 - idx := bodyRow - 8 + idx := bodyRow - m.confirmHeaderRows() if idx >= 0 && idx < len(m.confirmRows()) { return "row", idx } diff --git a/internal/ui/tui/wizard/git.go b/internal/ui/tui/wizard/git.go index 884f7cc..b8dcb35 100644 --- a/internal/ui/tui/wizard/git.go +++ b/internal/ui/tui/wizard/git.go @@ -20,6 +20,12 @@ func (m Model) needsGitCapture() (need bool, name, email string) { if m.opts.PackagesOnly { return false, "", "" } + // Config-mode installs are declarative — the git step already no-ops + // safely when no identity exists (matching the slug pipeline path), so + // don't interpose a prompt the config never asked for. + if m.rc != nil { + return false, "", "" + } name, email = gitConfigLookup() if name != "" && email != "" { return false, name, email diff --git a/internal/ui/tui/wizard/install.go b/internal/ui/tui/wizard/install.go index e6c6b9f..23091de 100644 --- a/internal/ui/tui/wizard/install.go +++ b/internal/ui/tui/wizard/install.go @@ -64,8 +64,17 @@ func (r chanReporter) Muted(s string) { r.ch <- reporterMsg{kind: rMuted, text // ── 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 := installer.PlanFromSelection(m.opts, m.selected, m.selectedOnlinePkgs()) + 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. @@ -93,7 +102,12 @@ func (m Model) startInstall() (tea.Model, tea.Cmd) { plan.Silent = true m.plan = plan - m.phases = buildPhases(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 @@ -106,7 +120,7 @@ func (m Model) startInstall() (tea.Model, tea.Cmd) { 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, plan), waitForEvent(m.events)) + return m, tea.Batch(m.spawnInstall(ctx, streamed), waitForEvent(m.events)) } // spawnInstall runs the install engine on a background goroutine, streaming diff --git a/internal/ui/tui/wizard/select.go b/internal/ui/tui/wizard/select.go index 00df46a..d4e6004 100644 --- a/internal/ui/tui/wizard/select.go +++ b/internal/ui/tui/wizard/select.go @@ -36,6 +36,27 @@ type searchDoneMsg struct { results []config.Package } +// categoriesFromConfig maps a remote config's package lists onto sidebar +// categories, so the select screen browses a config the same way it browses +// the catalog. Empty lists produce no category. +func categoriesFromConfig(rc *config.RemoteConfig) []config.Category { + var cats []config.Category + add := func(name string, entries config.PackageEntryList, cask, npm bool) { + if len(entries) == 0 { + return + } + pkgs := make([]config.Package, 0, len(entries)) + for _, e := range entries { + pkgs = append(pkgs, config.Package{Name: e.Name, Description: e.Desc, IsCask: cask, IsNpm: npm}) + } + cats = append(cats, config.Category{Name: name, Packages: pkgs}) + } + add("cli tools", rc.Packages, false, false) + add("apps", rc.Casks, true, false) + add("npm", rc.Npm, false, true) + return cats +} + // ── selection helpers ── func (m Model) isInstalled(name string) bool { return m.installed[name] } diff --git a/internal/ui/tui/wizard/status.go b/internal/ui/tui/wizard/status.go index 325ecdd..5c228e0 100644 --- a/internal/ui/tui/wizard/status.go +++ b/internal/ui/tui/wizard/status.go @@ -11,10 +11,14 @@ import ( func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right string) { switch m.screen { case scrBoot: + cmdline := "~ % openboot install" + if m.srcLabel != "" { + cmdline += " " + m.srcLabel + } if m.probeIdx < len(m.probes) { - return "BOOT", cInfo, "probing this Mac…", "~ % openboot install" + return "BOOT", cInfo, "probing this Mac…", cmdline } - return "BOOT", cInfo, "1 / 2 / 3 pick a loadout · c hand-pick from scratch · ↵ select", "~ % openboot install" + return "BOOT", cInfo, "1 / 2 / 3 pick a loadout · c hand-pick from scratch · ↵ select", cmdline case scrSelect: return "SELECT", cAccent, diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go index f9cfc9d..a1b7740 100644 --- a/internal/ui/tui/wizard/wizard.go +++ b/internal/ui/tui/wizard/wizard.go @@ -4,9 +4,11 @@ // // 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. Non-interactive paths (--silent, -// --dry-run, --from, -u, sync) never reach the full wizard; slug/sync installs -// reuse only the live install screen via RunPipeline. +// (-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). package wizard import ( @@ -57,6 +59,13 @@ type Model struct { version string opts *config.InstallOptions + // rc, when non-nil, puts the wizard in config mode (install / -u / + // --from / alias): the select screen shows the config's own packages + // instead of the catalog, probing auto-advances past the loadout question, + // and the plan is built from the filtered config. + rc *config.RemoteConfig + srcLabel string // "user/slug" (or config name) shown in the status bar + width, height int screen screen ticks int // monotonic, drives spinner + elapsed @@ -140,6 +149,33 @@ func New(version string, opts *config.InstallOptions) Model { } } +// NewForConfig builds a wizard model for a remote-config install: the sidebar +// categories are the config's own package lists, everything preselected — +// review-and-prune, mirroring the config's declarative intent. +func NewForConfig(version string, opts *config.InstallOptions, rc *config.RemoteConfig) Model { + m := New(version, opts) + m.rc = rc + m.srcLabel = configLabel(rc) + m.cats = categoriesFromConfig(rc) + for _, c := range m.cats { + for _, p := range c.Packages { + m.selected[p.Name] = true + } + } + return m +} + +// configLabel names a remote config for display: user/slug when known. +func configLabel(rc *config.RemoteConfig) string { + if rc.Username != "" && rc.Slug != "" { + return rc.Username + "/" + rc.Slug + } + if rc.Name != "" { + return rc.Name + } + return "config" +} + func (m Model) Init() tea.Cmd { if m.pipelineRun != nil { // waitForEvent is essential: it drains m.events into Update. Without it @@ -421,8 +457,19 @@ func truncCell(s string, w int) string { // flow keeps the TUI alive until the engine goroutine reports done, so the // redirect isn't restored under its feet. func Run(version string, opts *config.InstallOptions) (plan installer.InstallPlan, confirmed bool, err error) { - m := New(version, opts) + 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). +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() From c2039482859442df9a177c18b59c43b35a309c54 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Thu, 16 Jul 2026 01:34:04 +0800 Subject: [PATCH 4/4] refactor(tui): extract onCtrlC to keep Model.Update under the gocyclo gate The two online-search message cases pushed Update to complexity 22 (gate is 20). Moving the ctrl+c abort semantics into their own method reads better than a nolint and drops Update back under the gate. --- internal/ui/tui/wizard/wizard.go | 43 ++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go index a1b7740..4b0b965 100644 --- a/internal/ui/tui/wizard/wizard.go +++ b/internal/ui/tui/wizard/wizard.go @@ -217,25 +217,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: if msg.String() == "ctrl+c" { - // 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. - 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 + return m.onCtrlC() } switch m.screen { case scrBoot: @@ -268,6 +250,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 {