Files
Archduino/docs/superpowers/plans/2026-04-13-installer-tui.md
2026-04-13 21:40:44 +02:00

41 KiB

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

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:

// 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:

// 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:

// 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
go build ./...

Expected: clean build, no changes outside the new dirs + go.mod/go.sum.

  • Step 4: Commit
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

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
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
go test ./internal/tui/...

Expected: PASS.

  • Step 4: Commit
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

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
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
go test ./internal/tui/...

Expected: PASS.

  • Step 4: Commit
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

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.

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
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
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

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
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
go test ./internal/tui/dashboard/...

Expected: PASS (4 tests).

  • Step 4: Commit
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

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.

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:

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:

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
go test ./internal/tui/dashboard/...

Expected: PASS.

  • Step 4: Commit
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:

			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:

			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:

	"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:

			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:

			ended := time.Now().UTC()
			err = runErr
			if err != nil {
				return err
			}
  • Step 2: Build and smoke test
go build ./cmd/archduino

Plain path (what Plan 1 had — must still work):

rm -f ~/.cache/archduino/hello.ok
./archduino install --no-tui --no-sudo
test -f ~/.cache/archduino/hello.ok && echo OK

Dry-run plain path:

./archduino install --no-tui --no-sudo --dry-run

TUI path (manual — requires a real terminal):

./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
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

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:

func (r *recorderSender) Send(msg tea.Msg) { r.msgs = append(r.msgs, msg) }

and add the tea import.

  • Step 2: Run tests
go test ./internal/tui/dashboard/...

Expected: PASS.

  • Step 3: Commit
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.