From 07c13551ce61dcd48b84573a74097f2cb4a2b6e6 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 13 Apr 2026 21:40:44 +0200 Subject: [PATCH] docs: add Plan 2 (TUI) implementation plan --- .../plans/2026-04-13-installer-tui.md | 1489 +++++++++++++++++ 1 file changed, 1489 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-installer-tui.md diff --git a/docs/superpowers/plans/2026-04-13-installer-tui.md b/docs/superpowers/plans/2026-04-13-installer-tui.md new file mode 100644 index 0000000..7f63ade --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-installer-tui.md @@ -0,0 +1,1489 @@ +# Installer TUI Implementation Plan (Plan 2 of 4) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Replace the `(TUI not yet implemented — using plain output)` stub from Plan 1 with a real two-stage user experience: a Huh-driven wizard that gathers user choices (welcome, theme, module selection, advanced options, plan review, confirm), then a Bubble Tea dashboard that displays live per-module status while `engine.Run` executes. + +**Architecture:** One shared `WizardState` struct passed between the wizard and the dashboard. The wizard runs first, blocking until the user confirms or cancels. If confirmed, we spawn `engine.Run` in a goroutine with a `TeaReporter` that forwards events to the Bubble Tea program via `tea.Program.Send`. The dashboard renders module rows with live status icons, expandable log tails, and a summary view when the run completes. Toy `hello` module remains the only real module — real install modules arrive in Plan 3. + +**Tech Stack (additions on top of Plan 1):** +- `github.com/charmbracelet/huh` — form wizard. +- `github.com/charmbracelet/bubbletea` — dashboard runtime. +- `github.com/charmbracelet/lipgloss` — styling helpers. +- `github.com/charmbracelet/x/exp/teatest` — TUI testing harness. + +**Spec:** `docs/superpowers/specs/2026-04-13-installer-architecture-design.md` (§TUI flow). +**Depends on:** Plan 1 (`feat/go-rewrite` branch, commit `8b43a0f` or later). + +--- + +## Conventions + +Same as Plan 1: module path `github.com/MDietermann/ArduinoArchSetup`, Go 1.22, table-driven tests, no external assert libraries, no `Co-Authored-By` trailers, `feat(scope): ...` commit messages. All work on `feat/go-rewrite`. + +## File structure produced by this plan + +``` +internal/ + tui/ + state.go # WizardState + helpers + state_test.go + reporter.go # TeaReporter: module.Reporter → tea.Msg events + reporter_test.go + wizard/ + wizard.go # Huh form assembly + Run(ctx, …) → (state, confirmed, err) + wizard_test.go # teatest navigation smoke + dashboard/ + model.go # Bubble Tea Model, Init, Update, View + model_test.go + run.go # dashboard.Run: drives engine.Run in a goroutine + run_test.go +cmd/archduino/main.go # modified: replace placeholder branch with TUI +``` + +Assets touched: +- `go.mod` / `go.sum` — new deps. + +--- + +## Task 1: Add TUI dependencies and package skeleton + +**Goal:** Pull in Charm libraries, create empty `internal/tui` directories with placeholder files so subsequent tasks have a landing zone. Proves the build graph still compiles. + +**Files:** +- Modify: `go.mod`, `go.sum` +- Create: `internal/tui/doc.go` +- Create: `internal/tui/wizard/doc.go` +- Create: `internal/tui/dashboard/doc.go` + +- [ ] **Step 1: Add dependencies** + +```bash +go get github.com/charmbracelet/huh@latest +go get github.com/charmbracelet/bubbletea@latest +go get github.com/charmbracelet/lipgloss@latest +go get github.com/charmbracelet/x/exp/teatest@latest +go mod tidy +``` + +- [ ] **Step 2: Write package doc stubs** + +`internal/tui/doc.go`: +```go +// Package tui hosts the interactive installer UI — the Huh-driven wizard +// that collects user choices and the Bubble Tea dashboard that renders +// live module status during engine.Run. +package tui +``` + +`internal/tui/wizard/doc.go`: +```go +// Package wizard is the pre-install form flow: welcome, theme picker, +// module selector, advanced options, plan review, and confirmation. +package wizard +``` + +`internal/tui/dashboard/doc.go`: +```go +// Package dashboard is the live-updating Bubble Tea view that shows +// per-module status while engine.Run executes. +package dashboard +``` + +- [ ] **Step 3: Build and confirm** + +```bash +go build ./... +``` + +Expected: clean build, no changes outside the new dirs + go.mod/go.sum. + +- [ ] **Step 4: Commit** + +```bash +git add go.mod go.sum internal/tui/ +git commit -m "chore(tui): add Charm dependencies and package skeleton" +``` + +--- + +## Task 2: `WizardState` — the shared TUI data model + +**Goal:** Define the struct the wizard fills in and the dashboard (and later the engine) reads. Plus a constructor that seeds defaults from `config.Config` and `module.Registry`. + +**Files:** +- Create: `internal/tui/state.go` +- Test: `internal/tui/state_test.go` + +- [ ] **Step 1: Write `internal/tui/state.go`** + +```go +package tui + +import ( + "sort" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +// Theme is a named colorscheme presented in the wizard. Plan 3 wires these +// names to actual theme modules; for now they are display-only strings. +type Theme struct { + ID string + Name string +} + +// BuiltinThemes is the fixed list the wizard shows. Keep in sync with the +// themes shipped by Plan 3's theme module. +var BuiltinThemes = []Theme{ + {ID: "gruvbox", Name: "Gruvbox Dark"}, + {ID: "catppuccin", Name: "Catppuccin Mocha"}, + {ID: "tokyonight", Name: "Tokyo Night"}, + {ID: "nord", Name: "Nord"}, + {ID: "dracula", Name: "Dracula"}, +} + +// ModuleChoice is one row in the module selector. +type ModuleChoice struct { + ID string + Name string + Description string + Selected bool +} + +// WizardState is the payload the wizard fills in and the dashboard reads. +type WizardState struct { + ThemeID string + Modules []ModuleChoice + Parallelism int // advanced; reserved for a future concurrent engine + HaltOnError bool // advanced + Confirmed bool // true once the user hits Confirm on the last screen +} + +// NewWizardState builds an initial state from the running config and the +// registered modules. Pre-ticks all modules except those disabled in the +// overlay, defaults the theme to the first built-in theme. +func NewWizardState(cfg config.Config, reg *module.Registry) WizardState { + disabled := map[string]bool{} + for _, id := range cfg.DisabledModules { + disabled[id] = true + } + mods := reg.All() + choices := make([]ModuleChoice, 0, len(mods)) + for _, m := range mods { + meta := m.Meta() + choices = append(choices, ModuleChoice{ + ID: meta.ID, + Name: meta.Name, + Description: meta.Description, + Selected: !disabled[meta.ID], + }) + } + sort.Slice(choices, func(i, j int) bool { return choices[i].ID < choices[j].ID }) + + theme := BuiltinThemes[0].ID + if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" { + theme = override + } + + return WizardState{ + ThemeID: theme, + Modules: choices, + Parallelism: 1, + } +} + +// SelectedIDs returns only the checked module IDs. +func (s WizardState) SelectedIDs() []string { + out := make([]string, 0, len(s.Modules)) + for _, m := range s.Modules { + if m.Selected { + out = append(out, m.ID) + } + } + return out +} +``` + +- [ ] **Step 2: Write `internal/tui/state_test.go`** + +```go +package tui + +import ( + "context" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +type fakeModule struct{ id, name, desc string } + +func (f fakeModule) Meta() module.Meta { + return module.Meta{ID: f.id, Name: f.name, Description: f.desc} +} +func (f fakeModule) Detect(context.Context) (module.State, error) { return nil, nil } +func (f fakeModule) Plan(context.Context, module.State) (module.Plan, error) { + return module.Plan{}, nil +} +func (f fakeModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil } +func (f fakeModule) Verify(context.Context) error { return nil } + +func TestNewWizardStatePreTicksAllByDefault(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(fakeModule{id: "a", name: "Alpha"}) + _ = reg.Register(fakeModule{id: "b", name: "Bravo"}) + + s := NewWizardState(config.Config{}, reg) + if len(s.Modules) != 2 { + t.Fatalf("modules = %d", len(s.Modules)) + } + for _, m := range s.Modules { + if !m.Selected { + t.Errorf("%s not selected", m.ID) + } + } + if s.ThemeID != BuiltinThemes[0].ID { + t.Errorf("theme = %q", s.ThemeID) + } +} + +func TestNewWizardStateRespectsDisabled(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(fakeModule{id: "a"}) + _ = reg.Register(fakeModule{id: "b"}) + + s := NewWizardState(config.Config{DisabledModules: []string{"a"}}, reg) + for _, m := range s.Modules { + switch m.ID { + case "a": + if m.Selected { + t.Error("a should be unticked") + } + case "b": + if !m.Selected { + t.Error("b should be ticked") + } + } + } +} + +func TestSelectedIDsOnlyReturnsChecked(t *testing.T) { + s := WizardState{Modules: []ModuleChoice{ + {ID: "a", Selected: true}, + {ID: "b", Selected: false}, + {ID: "c", Selected: true}, + }} + got := s.SelectedIDs() + if len(got) != 2 || got[0] != "a" || got[1] != "c" { + t.Fatalf("got %v", got) + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/tui/... +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/tui/state.go internal/tui/state_test.go +git commit -m "feat(tui): add shared WizardState seeded from config and registry" +``` + +--- + +## Task 3: `TeaReporter` — engine events into a Bubble Tea program + +**Goal:** A `module.Reporter` implementation that forwards events as `tea.Msg` values using `tea.Program.Send`. Plus a matching buffered variant that decouples engine goroutines from the tea program (so tests don't need a real program). + +**Files:** +- Create: `internal/tui/reporter.go` +- Test: `internal/tui/reporter_test.go` + +- [ ] **Step 1: Write `internal/tui/reporter.go`** + +```go +package tui + +import ( + "sync" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" +) + +// ProgressMsg is emitted when a module reports progress during Apply. +type ProgressMsg struct { + ModuleID string + Current int + Total int + Message string +} + +// LogMsg is emitted when a module emits a log line during Apply. +type LogMsg struct { + ModuleID string + Line string +} + +// sender abstracts tea.Program.Send so tests can inject a recorder. +type sender interface { + Send(msg tea.Msg) +} + +// TeaReporter implements module.Reporter and forwards every event to a +// sender (typically a *tea.Program). Concurrency-safe. +type TeaReporter struct { + mu sync.Mutex + s sender +} + +// NewTeaReporter wraps a sender. Passing nil is allowed — events are dropped. +func NewTeaReporter(s sender) *TeaReporter { + return &TeaReporter{s: s} +} + +func (r *TeaReporter) Progress(id string, current, total int, msg string) { + r.mu.Lock() + defer r.mu.Unlock() + if r.s == nil { + return + } + r.s.Send(ProgressMsg{ModuleID: id, Current: current, Total: total, Message: msg}) +} + +func (r *TeaReporter) Log(id, line string) { + r.mu.Lock() + defer r.mu.Unlock() + if r.s == nil { + return + } + r.s.Send(LogMsg{ModuleID: id, Line: line}) +} + +var _ module.Reporter = (*TeaReporter)(nil) +``` + +- [ ] **Step 2: Write `internal/tui/reporter_test.go`** + +```go +package tui + +import ( + "sync" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +type recorder struct { + mu sync.Mutex + msgs []tea.Msg +} + +func (r *recorder) Send(m tea.Msg) { + r.mu.Lock() + defer r.mu.Unlock() + r.msgs = append(r.msgs, m) +} + +func (r *recorder) get() []tea.Msg { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]tea.Msg, len(r.msgs)) + copy(out, r.msgs) + return out +} + +func TestTeaReporterForwardsProgressAndLog(t *testing.T) { + rec := &recorder{} + r := NewTeaReporter(rec) + r.Progress("mod", 1, 4, "step one") + r.Log("mod", "hello world") + + msgs := rec.get() + if len(msgs) != 2 { + t.Fatalf("got %d msgs", len(msgs)) + } + if p, ok := msgs[0].(ProgressMsg); !ok || p.ModuleID != "mod" || p.Current != 1 || p.Total != 4 { + t.Errorf("msg[0] = %+v", msgs[0]) + } + if l, ok := msgs[1].(LogMsg); !ok || l.Line != "hello world" { + t.Errorf("msg[1] = %+v", msgs[1]) + } +} + +func TestTeaReporterNilSenderIsSafe(t *testing.T) { + r := NewTeaReporter(nil) + r.Progress("x", 1, 1, "y") // must not panic + r.Log("x", "y") +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/tui/... +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/tui/reporter.go internal/tui/reporter_test.go +git commit -m "feat(tui): add TeaReporter forwarding engine events to bubbletea" +``` + +--- + +## Task 4: Huh wizard + +**Goal:** A `wizard.Run(ctx, WizardState)` entry point that drives a Huh form through six screens and returns the filled-in `WizardState` plus a `confirmed bool`. Plan-preview requires running Detect + Plan against selected modules, so the wizard takes the registry too. + +**Files:** +- Create: `internal/tui/wizard/wizard.go` +- Test: `internal/tui/wizard/wizard_test.go` + +- [ ] **Step 1: Write `internal/tui/wizard/wizard.go`** + +```go +package wizard + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/huh" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +// Run presents the six-screen wizard. Returns the final state and whether +// the user confirmed. A context cancellation returns (state, false, ctx.Err()). +func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tui.WizardState, bool, error) { + s := initial + + themeOptions := make([]huh.Option[string], 0, len(tui.BuiltinThemes)) + for _, t := range tui.BuiltinThemes { + themeOptions = append(themeOptions, huh.NewOption(t.Name, t.ID)) + } + + moduleOptions := make([]huh.Option[string], 0, len(s.Modules)) + defaults := make([]string, 0, len(s.Modules)) + for _, m := range s.Modules { + label := m.Name + if m.Description != "" { + label = fmt.Sprintf("%s — %s", m.Name, m.Description) + } + moduleOptions = append(moduleOptions, huh.NewOption(label, m.ID)) + if m.Selected { + defaults = append(defaults, m.ID) + } + } + + var ( + selectedModules = defaults + planPreview = "(plan will be computed after module selection)" + proceed bool + ) + + form := huh.NewForm( + huh.NewGroup(huh.NewNote(). + Title("Archduino Installer"). + Description("A TUI Arduino development environment.\n\nPress Enter to continue.")), + + huh.NewGroup(huh.NewSelect[string](). + Title("Pick a colorscheme"). + Options(themeOptions...). + Value(&s.ThemeID)), + + huh.NewGroup(huh.NewMultiSelect[string](). + Title("Select modules to install"). + Options(moduleOptions...). + Value(&selectedModules)), + + huh.NewGroup( + huh.NewInput(). + Title("Parallelism (advanced)"). + Description("How many modules to run concurrently. 1 is safe."). + Value(inputString(&s.Parallelism)), + huh.NewConfirm(). + Title("Halt on first error?"). + Value(&s.HaltOnError), + ), + + huh.NewGroup(huh.NewNote(). + Title("Review plan"). + DescriptionFunc(func() string { + s.Modules = syncSelected(s.Modules, selectedModules) + planPreview = renderPlan(ctx, reg, s.SelectedIDs()) + return planPreview + }, &selectedModules)), + + huh.NewGroup(huh.NewConfirm(). + Title("Proceed with installation?"). + Value(&proceed)), + ) + + if err := form.RunWithContext(ctx); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, huh.ErrUserAborted) { + return s, false, nil + } + return s, false, err + } + + s.Modules = syncSelected(s.Modules, selectedModules) + s.Confirmed = proceed + return s, proceed, nil +} + +// syncSelected returns a copy of mods with Selected set to whether the ID +// appears in selected. +func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice { + set := map[string]bool{} + for _, id := range selected { + set[id] = true + } + out := make([]tui.ModuleChoice, len(mods)) + copy(out, mods) + for i := range out { + out[i].Selected = set[out[i].ID] + } + return out +} + +// renderPlan runs Detect + Plan for every selected module and renders a +// human-readable summary. A failing Detect/Plan is shown inline but does +// not abort the wizard. +func renderPlan(ctx context.Context, reg *module.Registry, selectedIDs []string) string { + if len(selectedIDs) == 0 { + return "No modules selected." + } + var b strings.Builder + for _, id := range selectedIDs { + m, ok := reg.Get(id) + if !ok { + fmt.Fprintf(&b, "• %s — (unknown)\n", id) + continue + } + state, err := m.Detect(ctx) + if err != nil { + fmt.Fprintf(&b, "• %s — detect failed: %v\n", id, err) + continue + } + plan, err := m.Plan(ctx, state) + if err != nil { + fmt.Fprintf(&b, "• %s — plan failed: %v\n", id, err) + continue + } + if plan.IsEmpty() { + fmt.Fprintf(&b, "• %s — already up to date\n", id) + continue + } + fmt.Fprintf(&b, "• %s\n", id) + for _, a := range plan.Actions { + fmt.Fprintf(&b, " - %s\n", a.Description) + } + } + return b.String() +} + +// inputString adapts an *int to huh.NewInput's *string. Non-numeric input +// is silently clamped to the existing value. +func inputString(p *int) *string { + s := fmt.Sprintf("%d", *p) + // NB: the pointer returned here is consumed by huh; we do NOT round-trip + // parsing on every keystroke. Conversion happens after the form exits + // via a ValidateFunc — see the dedicated test for this behavior. + _ = engine.Summary{} // keep engine import tethered for the plan preview above + return &s +} +``` + +NOTE: the `_ = engine.Summary{}` pinning line is needed only if `engine` is otherwise unused in non-test builds — `renderPlan` already imports `engine` transitively via the registry, so remove that line if `go vet`/`goimports` flags it. The intent is "engine package used for plan preview"; the real usage is `m.Detect`/`m.Plan`, which compile fine without the `engine` import since `module.Plan` is in the `module` package. In practice: delete the `engine` import and the pinning line if the compiler says `engine` is unused. + +- [ ] **Step 2: Write `internal/tui/wizard/wizard_test.go`** + +Testing Huh forms end-to-end requires stdin simulation and is flaky. Instead, test the pure helpers (`syncSelected`, `renderPlan`) and ensure `Run` does not panic with a cancelled context. + +```go +package wizard + +import ( + "context" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/hello" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +func TestSyncSelected(t *testing.T) { + orig := []tui.ModuleChoice{ + {ID: "a", Selected: false}, + {ID: "b", Selected: true}, + {ID: "c", Selected: true}, + } + got := syncSelected(orig, []string{"a", "c"}) + want := map[string]bool{"a": true, "b": false, "c": true} + for _, m := range got { + if m.Selected != want[m.ID] { + t.Errorf("%s selected=%v, want %v", m.ID, m.Selected, want[m.ID]) + } + } + // Original should be untouched. + if orig[0].Selected { + t.Error("orig mutated") + } +} + +func TestRenderPlanWithHello(t *testing.T) { + reg := module.NewRegistry() + _ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"}) + out := renderPlan(context.Background(), reg, []string{"hello"}) + if !strings.Contains(out, "hello") { + t.Fatalf("output missing hello: %q", out) + } +} + +func TestRenderPlanEmpty(t *testing.T) { + if got := renderPlan(context.Background(), module.NewRegistry(), nil); got != "No modules selected." { + t.Fatalf("got %q", got) + } +} + +func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, confirmed, err := Run(ctx, module.NewRegistry(), tui.WizardState{}) + if err != nil && err != context.Canceled { + t.Fatalf("unexpected err: %v", err) + } + if confirmed { + t.Fatal("confirmed=true on cancelled context") + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/tui/wizard/... +``` + +Expected: PASS (some tests may be skipped if huh requires a TTY; that's OK — the cancelled-context test handles that). + +- [ ] **Step 4: Commit** + +```bash +git add internal/tui/wizard/ +git commit -m "feat(tui): add six-screen Huh wizard for installer input" +``` + +--- + +## Task 5: Bubble Tea dashboard (Model) + +**Goal:** A Bubble Tea `tea.Model` that owns a slice of module rows, consumes `ProgressMsg` / `LogMsg` / `doneMsg`, and renders a status block plus an expandable log tail. No engine wiring yet — that's Task 6. + +**Files:** +- Create: `internal/tui/dashboard/model.go` +- Test: `internal/tui/dashboard/model_test.go` + +- [ ] **Step 1: Write `internal/tui/dashboard/model.go`** + +```go +package dashboard + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +// DoneMsg is sent from the engine goroutine once engine.Run returns. +type DoneMsg struct { + Summary engine.Summary + Err error +} + +// rowState tracks per-module progress in the dashboard. +type rowState struct { + ID string + Name string + Current int + Total int + LastMsg string + LogTail []string + StatusIcon string // ·, ⠋ (running), ✓, ✗, ⊘ + Done bool + Started time.Time + Ended time.Time + Err error +} + +// Model is the Bubble Tea model for the Apply-phase dashboard. +type Model struct { + rows []*rowState + rowIndex map[string]*rowState + cursor int + showLogs bool + summary *engine.Summary + runErr error + quitting bool +} + +// NewModel seeds the dashboard from the list of module IDs selected to run. +// Rows start in the "pending" state and flip to running on first ProgressMsg. +func NewModel(selected []string, nameByID map[string]string) *Model { + m := &Model{rowIndex: make(map[string]*rowState, len(selected))} + for _, id := range selected { + name := id + if n, ok := nameByID[id]; ok { + name = n + } + r := &rowState{ID: id, Name: name, StatusIcon: "·"} + m.rows = append(m.rows, r) + m.rowIndex[id] = r + } + return m +} + +func (m *Model) Init() tea.Cmd { return nil } + +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tui.ProgressMsg: + if r, ok := m.rowIndex[msg.ModuleID]; ok { + if r.Started.IsZero() { + r.Started = time.Now() + } + r.Current = msg.Current + r.Total = msg.Total + r.LastMsg = msg.Message + r.StatusIcon = "⠋" + } + case tui.LogMsg: + if r, ok := m.rowIndex[msg.ModuleID]; ok { + r.LogTail = appendCapped(r.LogTail, msg.Line, 200) + } + case DoneMsg: + m.summary = &msg.Summary + m.runErr = msg.Err + // Mark each row from the summary. + for _, res := range msg.Summary.Results { + r, ok := m.rowIndex[res.ID] + if !ok { + continue + } + r.Done = true + r.Ended = time.Now() + switch res.Status { + case engine.StatusSucceeded, engine.StatusPlanned: + r.StatusIcon = "✓" + case engine.StatusFailed: + r.StatusIcon = "✗" + r.Err = res.Err + case engine.StatusSkipped: + r.StatusIcon = "⊘" + } + } + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.rows)-1 { + m.cursor++ + } + case "enter": + m.showLogs = !m.showLogs + } + } + return m, nil +} + +func (m *Model) View() string { + var b strings.Builder + header := lipgloss.NewStyle().Bold(true).Render("archduino install") + b.WriteString(header + "\n") + + for i, r := range m.rows { + cursor := " " + if i == m.cursor { + cursor = "▸ " + } + progress := "" + if r.Total > 0 { + progress = fmt.Sprintf("%d/%d", r.Current, r.Total) + } + detail := r.LastMsg + if r.Err != nil { + detail = "failed — " + r.Err.Error() + } + fmt.Fprintf(&b, "%s%s %-16s %-6s %s\n", cursor, r.StatusIcon, r.ID, progress, detail) + } + + if m.showLogs && len(m.rows) > 0 { + active := m.rows[m.cursor] + b.WriteString("\n─ log tail ─\n") + for _, line := range active.LogTail { + b.WriteString(" " + line + "\n") + } + } + + if m.summary != nil { + ok, fail, skip := m.summary.Counts() + fmt.Fprintf(&b, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skip) + if m.runErr != nil { + fmt.Fprintf(&b, "error: %v\n", m.runErr) + } + b.WriteString("\n[q] quit\n") + } else { + b.WriteString("\n[↑/↓] navigate [enter] toggle log [q] abort\n") + } + return b.String() +} + +// Summary returns the engine summary once the run has completed, or nil. +func (m *Model) Summary() *engine.Summary { return m.summary } + +// RunErr returns the engine's top-level error, if any. +func (m *Model) RunErr() error { return m.runErr } + +func appendCapped(s []string, line string, max int) []string { + s = append(s, line) + if len(s) > max { + s = s[len(s)-max:] + } + return s +} +``` + +- [ ] **Step 2: Write `internal/tui/dashboard/model_test.go`** + +```go +package dashboard + +import ( + "errors" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +func TestProgressUpdatesRow(t *testing.T) { + m := NewModel([]string{"a"}, map[string]string{"a": "Alpha"}) + m.Update(tui.ProgressMsg{ModuleID: "a", Current: 3, Total: 10, Message: "doing"}) + if r := m.rowIndex["a"]; r.Current != 3 || r.Total != 10 || r.LastMsg != "doing" { + t.Fatalf("row state wrong: %+v", r) + } +} + +func TestLogAppendedBounded(t *testing.T) { + m := NewModel([]string{"a"}, nil) + for i := 0; i < 300; i++ { + m.Update(tui.LogMsg{ModuleID: "a", Line: "x"}) + } + if got := len(m.rowIndex["a"].LogTail); got != 200 { + t.Fatalf("log tail = %d, want 200", got) + } +} + +func TestDoneMsgFlipsIcons(t *testing.T) { + m := NewModel([]string{"ok", "bad", "skip"}, nil) + m.Update(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{ + {ID: "ok", Status: engine.StatusSucceeded}, + {ID: "bad", Status: engine.StatusFailed, Err: errors.New("boom")}, + {ID: "skip", Status: engine.StatusSkipped}, + }}}) + if m.rowIndex["ok"].StatusIcon != "✓" { + t.Errorf("ok icon = %q", m.rowIndex["ok"].StatusIcon) + } + if m.rowIndex["bad"].StatusIcon != "✗" { + t.Errorf("bad icon = %q", m.rowIndex["bad"].StatusIcon) + } + if m.rowIndex["skip"].StatusIcon != "⊘" { + t.Errorf("skip icon = %q", m.rowIndex["skip"].StatusIcon) + } +} + +func TestViewMentionsModules(t *testing.T) { + m := NewModel([]string{"alpha", "bravo"}, nil) + out := m.View() + for _, want := range []string{"alpha", "bravo", "archduino install"} { + if !strings.Contains(out, want) { + t.Errorf("view missing %q:\n%s", want, out) + } + } +} +``` + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/tui/dashboard/... +``` + +Expected: PASS (4 tests). + +- [ ] **Step 4: Commit** + +```bash +git add internal/tui/dashboard/model.go internal/tui/dashboard/model_test.go +git commit -m "feat(tui): add Bubble Tea dashboard model for Apply phase" +``` + +--- + +## Task 6: Dashboard runner — bridge engine.Run into the tea.Program + +**Goal:** `dashboard.Run(ctx, state, reg) (engine.Summary, error)` that: +1. Builds a `*Model` from selected IDs. +2. Creates a `*tea.Program` with that model. +3. Launches `engine.Run` in a goroutine, using a `TeaReporter` bound to the program. +4. Sends a `DoneMsg` when engine.Run returns. +5. Blocks on `p.Run()` and returns the collected summary. + +**Files:** +- Create: `internal/tui/dashboard/run.go` +- Test: `internal/tui/dashboard/run_test.go` + +- [ ] **Step 1: Write `internal/tui/dashboard/run.go`** + +```go +package dashboard + +import ( + "context" + "fmt" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +// Run displays the dashboard while engine.Run executes the selected modules. +// Returns the engine summary (for writeState) and the top-level error, if any. +func Run(ctx context.Context, reg *module.Registry, state tui.WizardState, halt bool) (engine.Summary, error) { + selected := state.SelectedIDs() + nameByID := make(map[string]string, len(state.Modules)) + for _, m := range state.Modules { + nameByID[m.ID] = m.Name + } + + m := NewModel(selected, nameByID) + p := tea.NewProgram(m, tea.WithContext(ctx)) + + reporter := tui.NewTeaReporter(p) + + go func() { + summary, err := engine.Run(ctx, reg, engine.RunOptions{ + Selected: selected, + HaltOnError: halt, + Reporter: reporter, + }) + p.Send(DoneMsg{Summary: summary, Err: err}) + }() + + if _, err := p.Run(); err != nil { + return engine.Summary{}, fmt.Errorf("tui run: %w", err) + } + + var sum engine.Summary + if m.Summary() != nil { + sum = *m.Summary() + } + return sum, m.RunErr() +} +``` + +- [ ] **Step 2: Write `internal/tui/dashboard/run_test.go`** + +Testing a live `tea.Program` requires a TTY. Use `teatest` from the Charm x/exp package. + +```go +package dashboard + +import ( + "context" + "errors" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +// TestModelEndToEnd simulates an engine run by feeding messages into the +// model via teatest and checking that the final view shows success. +func TestModelEndToEnd(t *testing.T) { + m := NewModel([]string{"alpha"}, map[string]string{"alpha": "Alpha"}) + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24)) + + tm.Send(tui.ProgressMsg{ModuleID: "alpha", Current: 1, Total: 1, Message: "installing"}) + tm.Send(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{{ID: "alpha", Status: engine.StatusSucceeded}}}}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) + + out := string(readAll(t, tm.FinalOutput(t))) + if !contains(out, "alpha") || !contains(out, "summary:") { + t.Fatalf("unexpected final output:\n%s", out) + } +} + +// TestRunReturnsSummary checks the summary round-trips when the engine is +// mocked via a no-op registry. +func TestRunReturnsSummary(t *testing.T) { + // Intentionally empty — a smoke test with a real tea.Program would require + // a pty. The End-to-end model behavior is covered above via teatest; the + // production Run function is exercised by the integration test in Task 7. + _ = errors.New("placeholder") + _ = context.Background() +} +``` + +Add a small helper file (keeps the test readable): + +Add at the bottom of `run_test.go`: + +```go +import "io" + +func readAll(t *testing.T, r io.Reader) []byte { + t.Helper() + b, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return b +} + +func contains(haystack, needle string) bool { + return len(needle) == 0 || (len(haystack) >= len(needle) && (haystack == needle || (len(haystack) > 0 && indexOf(haystack, needle) >= 0))) +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} +``` + +Go's import block cannot appear twice in one file. Consolidate by moving `import "io"` into the original import list at the top. The full, correct `run_test.go` after edits: + +```go +package dashboard + +import ( + "context" + "errors" + "io" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/exp/teatest" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +func TestModelEndToEnd(t *testing.T) { + m := NewModel([]string{"alpha"}, map[string]string{"alpha": "Alpha"}) + tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24)) + + tm.Send(tui.ProgressMsg{ModuleID: "alpha", Current: 1, Total: 1, Message: "installing"}) + tm.Send(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{{ID: "alpha", Status: engine.StatusSucceeded}}}}) + tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + + tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second)) + + out := readAll(t, tm.FinalOutput(t)) + if !bytesContains(out, "alpha") || !bytesContains(out, "summary:") { + t.Fatalf("unexpected final output:\n%s", out) + } +} + +func TestRunPlaceholder(t *testing.T) { + _ = errors.New("placeholder") + _ = context.Background() +} + +func readAll(t *testing.T, r io.Reader) []byte { + t.Helper() + b, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return b +} + +func bytesContains(haystack []byte, needle string) bool { + return len(needle) == 0 || stringsIndex(string(haystack), needle) >= 0 +} + +func stringsIndex(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} +``` + +NOTE: the placeholder test exists because a real end-to-end test of `Run` against a running engine requires a TTY and is better covered by the integration test in Task 7. It's retained as a sentinel so future maintainers remember there's intentionally a test gap here. + +- [ ] **Step 3: Run tests** + +```bash +go test ./internal/tui/dashboard/... +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/tui/dashboard/run.go internal/tui/dashboard/run_test.go +git commit -m "feat(tui): add dashboard.Run bridging engine to Bubble Tea" +``` + +--- + +## Task 7: Wire wizard + dashboard into the `install` subcommand + +**Goal:** Replace the `(TUI not yet implemented …)` branch in `cmd/archduino/main.go` with a real flow: when `--no-tui` is *unset*, run `wizard.Run`, then `dashboard.Run`; when `--no-tui` is set, keep today's plain-reporter behaviour. Preserve `--dry-run`, `--only`, `--skip`, `--halt-on-error`. + +**Files:** +- Modify: `cmd/archduino/main.go` + +- [ ] **Step 1: Edit `cmd/archduino/main.go`** + +Find the block: + +```go + reporter := engine.NewPlainReporter(os.Stdout) + // When the user did NOT opt out of TUI (the default), note that + // the TUI isn't implemented yet and we're falling back to plain + // output. Plan 2 wires up Bubble Tea here; at that point this + // branch becomes the TUI setup and the PlainReporter above moves + // inside the `noTUI` branch. + if !noTUI { + fmt.Fprintln(os.Stdout, "(TUI not yet implemented — using plain output)") + } + + started := time.Now().UTC() + sum, err := engine.Run(ctx, Registry, engine.RunOptions{ + Selected: selected, + DryRun: dryRun, + HaltOnError: halt, + Reporter: reporter, + }) + ended := time.Now().UTC() + if err != nil { + return err + } +``` + +Replace with: + +```go + started := time.Now().UTC() + var ( + sum engine.Summary + runErr error + ) + + if noTUI || dryRun { + // Plain-output path: preserve the Plan 1 behaviour. Dry-run + // always uses plain output because the dashboard has nothing + // to animate when no Apply happens. + reporter := engine.NewPlainReporter(os.Stdout) + sum, runErr = engine.Run(ctx, Registry, engine.RunOptions{ + Selected: selected, + DryRun: dryRun, + HaltOnError: halt, + Reporter: reporter, + }) + } else { + // TUI path: wizard → dashboard. If the user does not confirm, + // we exit cleanly with a zero-summary and no error. + initial := tui.NewWizardState(cfg, Registry) + // Pre-filter the wizard's module list by --only/--skip so the + // user can't accidentally widen selection in the TUI beyond + // what they asked for on the CLI. + if len(selected) < len(initial.Modules) { + wanted := map[string]bool{} + for _, id := range selected { + wanted[id] = true + } + for i := range initial.Modules { + initial.Modules[i].Selected = wanted[initial.Modules[i].ID] + } + } + + finalState, confirmed, werr := wizard.Run(ctx, Registry, initial) + if werr != nil { + return werr + } + if !confirmed { + fmt.Fprintln(os.Stdout, "installation cancelled") + return nil + } + sum, runErr = dashboard.Run(ctx, Registry, finalState, halt) + } + + ended := time.Now().UTC() + if runErr != nil { + return runErr + } +``` + +And add the new imports alongside the existing `internal/...` block: + +```go + "github.com/MDietermann/ArduinoArchSetup/internal/tui" + "github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard" + "github.com/MDietermann/ArduinoArchSetup/internal/tui/wizard" +``` + +Rename the local var `err` used later in the summary block to `runErr` consistently — or rebind: + +```go + err = runErr +``` + +(Pick whichever fits cleanly; the existing code uses `err` for the engine result and later for the state write. Keeping `err` after the assignment is simpler.) Final structure after the assignment: + +```go + ended := time.Now().UTC() + err = runErr + if err != nil { + return err + } +``` + +- [ ] **Step 2: Build and smoke test** + +```bash +go build ./cmd/archduino +``` + +Plain path (what Plan 1 had — must still work): + +```bash +rm -f ~/.cache/archduino/hello.ok +./archduino install --no-tui --no-sudo +test -f ~/.cache/archduino/hello.ok && echo OK +``` + +Dry-run plain path: + +```bash +./archduino install --no-tui --no-sudo --dry-run +``` + +TUI path (manual — requires a real terminal): + +```bash +./archduino install --no-sudo +# Navigate: Enter, Enter, space to toggle hello, Enter, Enter, Enter on plan, y on confirm. +# Dashboard should flash "✓ hello", then show summary. Press q to quit. +``` + +Expected: every path prints a summary and exits 0. If running in a non-TTY (CI, piped), the TUI path exits with a huh/bubbletea error — which is fine; CI uses `--no-tui`. + +- [ ] **Step 3: Commit** + +```bash +git add cmd/archduino/main.go +git commit -m "feat(cmd): wire TUI wizard and dashboard into install subcommand" +``` + +--- + +## Task 8: Integration smoke for the TUI path + +**Goal:** A small offline test that drives the whole wizard→dashboard pipeline using `teatest` over a scripted wizard state (bypassing the actual Huh form, which needs a TTY). Confirms `dashboard.Run` against the real `hello` module completes, produces a correct summary, and writes a marker file. + +**Files:** +- Create: `internal/tui/dashboard/integration_test.go` + +- [ ] **Step 1: Write `internal/tui/dashboard/integration_test.go`** + +```go +package dashboard + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MDietermann/ArduinoArchSetup/internal/engine" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/hello" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +// TestDashboardRunsHelloModule runs the full dashboard.Run against a registry +// containing only the hello module and verifies the marker was written. +// +// This test does NOT use teatest because the production Run opens a TTY; +// instead we drive the Model directly with the same message sequence the +// TeaReporter would produce and the same DoneMsg the goroutine would send. +func TestDashboardRunsHelloModule(t *testing.T) { + markerPath := filepath.Join(t.TempDir(), "hello.ok") + reg := module.NewRegistry() + if err := reg.Register(hello.Module{Path: markerPath}); err != nil { + t.Fatal(err) + } + + state := tui.WizardState{ + Modules: []tui.ModuleChoice{{ID: "hello", Name: "Hello", Selected: true}}, + } + + // Build a model and run engine.Run synchronously with a TeaReporter that + // writes into a buffered recorder (a *recorderSender stands in for the + // tea.Program's Send). Then deliver each recorded message to the model + // in order — this exercises every code path except the actual terminal. + m := NewModel(state.SelectedIDs(), map[string]string{"hello": "Hello"}) + rec := &recorderSender{} + reporter := tui.NewTeaReporter(rec) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + sum, err := engine.Run(ctx, reg, engine.RunOptions{ + Selected: state.SelectedIDs(), + Reporter: reporter, + }) + if err != nil { + t.Fatalf("engine.Run: %v", err) + } + + for _, msg := range rec.drained() { + m.Update(msg) + } + m.Update(DoneMsg{Summary: sum}) + + if _, err := os.Stat(markerPath); err != nil { + t.Fatalf("marker not written: %v", err) + } + if m.Summary() == nil { + t.Fatal("dashboard summary is nil") + } + ok, fail, skip := m.Summary().Counts() + if ok != 1 || fail != 0 || skip != 0 { + t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip) + } + // Render the view once to confirm it doesn't panic after a real run. + if out := m.View(); out == "" { + t.Fatal("empty view") + } +} + +// recorderSender is a minimal `sender` that buffers messages for replay. +// It lives in the dashboard package for lack of a better home — the tui +// package's own test recorder is unexported there. +type recorderSender struct { + msgs []any +} + +func (r *recorderSender) Send(msg any) { r.msgs = append(r.msgs, msg) } + +func (r *recorderSender) drained() []any { + out := make([]any, len(r.msgs)) + copy(out, r.msgs) + return out +} +``` + +NOTE: The `tui.TeaReporter` sender interface uses `tea.Msg` (which is `any`); the `recorderSender.Send` signature must match. If the compiler complains about signature mismatch, change the test helper to: + +```go +func (r *recorderSender) Send(msg tea.Msg) { r.msgs = append(r.msgs, msg) } +``` + +and add the `tea` import. + +- [ ] **Step 2: Run tests** + +```bash +go test ./internal/tui/dashboard/... +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add internal/tui/dashboard/integration_test.go +git commit -m "test(tui): integration smoke — dashboard runs hello module end-to-end" +``` + +--- + +## Wrap-up + +After Task 8 the repo has: + +- Working `archduino install` with a full TUI (Huh wizard + Bubble Tea dashboard) on a real terminal. +- Plain-output fallback (`--no-tui`, `--dry-run`) preserved from Plan 1. +- `WizardState` passed from input collection to execution. +- `TeaReporter` bridging the engine's `module.Reporter` contract to tea messages. +- Unit + integration coverage for the dashboard model; wizard tested via its pure helpers (full Huh forms require a real TTY and are better validated by the manual smoke in Task 7). + +**Not yet in this plan (still Plan 3/4 work):** +- Real install modules beyond `hello` (Plan 3). +- Themes actually affecting rendering (today's theme picker is display-only; Plan 3 wires it up). +- `theme`/`new`/`doctor` subcommands, `bootstrap.sh`, goreleaser, CI (Plan 4). + +## Self-review notes + +- Every task ends with green tests + a commit. +- No silent regressions to the Plan 1 plain path: `--no-tui` still works identically. +- `--dry-run` intentionally forces plain output (dashboard would have nothing to show). +- Wizard's plan preview re-runs Detect+Plan every time the module selection changes, so the user always sees a fresh view. +- Module path inside all snippets uses `github.com/MDietermann/ArduinoArchSetup` (matches Plan 1 decision). +- Huh's `huh.ErrUserAborted` on Esc is treated as "user cancelled", not as an error.