ajhahn.de
← eeco
Go 1386 lines
package tui

import (
	"context"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"testing"
	"time"

	"github.com/ajhahnde/eeco/internal/config"
	"github.com/ajhahnde/eeco/internal/hooks"
	"github.com/ajhahnde/eeco/internal/memory"
	"github.com/charmbracelet/bubbles/spinner"
	tea "github.com/charmbracelet/bubbletea"
)

// TestMain pins the user-global config dir to an empty temp dir so the
// global config layer is a hermetic no-op and these tests never read the
// dev box's ~/.config/eeco.
func TestMain(m *testing.M) {
	gdir, err := os.MkdirTemp("", "eeco-global-")
	if err != nil {
		panic(err)
	}
	os.Setenv(config.GlobalConfigEnv, gdir)
	code := m.Run()
	os.RemoveAll(gdir)
	os.Exit(code)
}

// miniDotFrames lists the MiniDot spinner glyphs; a running footer renders
// one of them and an idle footer none.
const miniDotFrames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"

// updateGolden refreshes the snapshot files under testdata/. Pass with
// `go test ./internal/tui -run TestRenderHome -update` after a deliberate
// home-view change; commit the regenerated goldens with the code.
var updateGolden = flag.Bool("update", false, "rewrite golden files under testdata/")

// --- helpers ---

// repo returns a config for a fresh temp git repo. When init is true the
// workspace is scaffolded so memory/queue/gc operations are available.
func repo(t *testing.T, doInit bool) *config.Config {
	t.Helper()
	root := t.TempDir()
	if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
		t.Fatal(err)
	}
	cfg, err := config.Load(root, config.DefaultWorkspace)
	if err != nil {
		t.Fatal(err)
	}
	if doInit {
		if _, err := config.Init(cfg); err != nil {
			t.Fatal(err)
		}
	}
	return cfg
}

func asModel(t *testing.T, tm tea.Model) model {
	t.Helper()
	m, ok := tm.(model)
	if !ok {
		t.Fatalf("expected tui.model, got %T", tm)
	}
	return m
}

// --- non-interactive guarantee ---

func TestInteractive_TestEnvIsNonInteractive(t *testing.T) {
	// Under `go test` stdio is piped, so the control center must take
	// the digest path and never start an interactive loop (no hang).
	if interactive() {
		t.Fatal("interactive() true under test; would hang CI")
	}
}

func TestRun_NonTTYPrintsDigestExitsZero(t *testing.T) {
	cfg := repo(t, false)
	var out, errb strings.Builder
	code := Run(cfg, "9.9.9", &out, &errb)
	if code != 0 {
		t.Fatalf("Run exit %d, want 0", code)
	}
	if !strings.Contains(out.String(), "eeco 9.9.9") {
		t.Errorf("digest missing version:\n%s", out.String())
	}
	if errb.Len() != 0 {
		t.Errorf("unexpected stderr: %q", errb.String())
	}
}

// --- digest ---

func TestOneScreen_Fields(t *testing.T) {
	cfg := repo(t, false)
	s := OneScreen(cfg, "1.2.3")
	for _, want := range []string{
		"eeco 1.2.3", cfg.RepoRoot, "profile", "automation",
		"memory", "queue", "hooks", "missing — run `eeco init`",
	} {
		if !strings.Contains(s, want) {
			t.Errorf("OneScreen missing %q:\n%s", want, s)
		}
	}
	cfg = repo(t, true)
	if !strings.Contains(OneScreen(cfg, "x"), "(initialised)") {
		t.Error("initialised workspace not reflected")
	}
}

func TestOneScreen_DoctorHintFiresOnFreshInit(t *testing.T) {
	cfg := repo(t, true)
	s := OneScreen(cfg, "x")
	if !strings.Contains(s, "eeco doctor") {
		t.Errorf("expected doctor hint on fresh init, got:\n%s", s)
	}
}

func TestOneScreen_DoctorHintSuppressedOnceQueueHasItem(t *testing.T) {
	cfg := repo(t, true)
	// Plant a queue item to count as observable activity.
	stateDir := filepath.Join(cfg.Workspace, "state")
	if err := os.MkdirAll(stateDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(
		filepath.Join(stateDir, "queue.md"),
		[]byte("# eeco queue\n\n- [ ] **k** — t _(p, 2026-05-19)_\n"),
		0o644,
	); err != nil {
		t.Fatal(err)
	}
	s := OneScreen(cfg, "x")
	if strings.Contains(s, "eeco doctor") {
		t.Errorf("hint should be suppressed once activity exists:\n%s", s)
	}
}

func TestOneScreen_DoctorHintSuppressedOnceWorkflowScaffolded(t *testing.T) {
	cfg := repo(t, true)
	wfDir := filepath.Join(cfg.Workspace, "workflows", "demo")
	if err := os.MkdirAll(wfDir, 0o755); err != nil {
		t.Fatal(err)
	}
	s := OneScreen(cfg, "x")
	if strings.Contains(s, "eeco doctor") {
		t.Errorf("hint should be suppressed once a user workflow exists:\n%s", s)
	}
}

func TestOneScreen_DoctorHintAbsentBeforeInit(t *testing.T) {
	cfg := repo(t, false)
	s := OneScreen(cfg, "x")
	if strings.Contains(s, "eeco doctor") {
		t.Errorf("hint should not fire before init:\n%s", s)
	}
}

func TestBarLine_LiveCounts(t *testing.T) {
	cfg := repo(t, true)
	b := barLine(cfg, "0.0.0", "")
	for _, want := range []string{"mem:0", "q:0", "auto:propose"} {
		if !strings.Contains(b, want) {
			t.Errorf("barLine missing %q: %s", want, b)
		}
	}
	// Version is intentionally elided from the bar — the home block already
	// surfaces it once on session start.
	if strings.Contains(b, "eeco 0.0.0") {
		t.Errorf("barLine should not duplicate the version banner: %s", b)
	}
	// An empty lastRun is omitted entirely (no placeholder noise).
	if strings.Contains(b, "run:") {
		t.Errorf("empty run should be omitted, got: %s", b)
	}
	// A real lastRun surfaces as a labelled field.
	if b := barLine(cfg, "0.0.0", "leak-guard: ok (exit 0)"); !strings.Contains(b, "run:leak-guard") {
		t.Errorf("barLine missing run field: %s", b)
	}
}

// --- parsing & completion ---

func TestParseInput(t *testing.T) {
	if p := parseInput("   "); p.name != "" || p.free != "" {
		t.Errorf("blank should be a no-op, got %+v", p)
	}
	p := parseInput("/run --ai comment-hygiene")
	if p.name != "run" || len(p.args) != 1 || p.args[0] != "comment-hygiene" || !p.ai {
		t.Errorf("parse /run --ai: %+v", p)
	}
	if p := parseInput("why is the gate failing"); p.free != "why is the gate failing" {
		t.Errorf("free text not captured: %+v", p)
	}
	if p := parseInput("/quit"); p.name != "quit" {
		t.Errorf("parse /quit: %+v", p)
	}
}

func TestComplete(t *testing.T) {
	// Ambiguous command prefix -> longest common prefix + candidates.
	got, cands := complete("/q", nil)
	if got != "/qu" || len(cands) != 2 {
		t.Errorf("/q completion: got %q cands %v", got, cands)
	}
	// Unique command prefix -> full command and a trailing space.
	if got, _ := complete("/he", nil); got != "/help " {
		t.Errorf("/he completion: %q", got)
	}
	// `/run` argument completes against the workflow names.
	got, _ = complete("/run comm", []string{"comment-hygiene", "leak-guard"})
	if got != "/run comment-hygiene " {
		t.Errorf("/run arg completion: %q", got)
	}
	// Free text never completes.
	if got, c := complete("explain", nil); got != "explain" || c != nil {
		t.Errorf("free text should not complete: %q %v", got, c)
	}
}

// --- dispatch ---

func TestDispatch_SyncCommands(t *testing.T) {
	cfg := repo(t, true)
	st := newStyles(false)
	disp := func(input string) dispatchResult {
		return dispatch(cfg, st, 80, parseInput(input))
	}
	join := func(r dispatchResult) string { return strings.Join(r.lines, "\n") }

	if r := disp("/quit"); !r.quit {
		t.Error("/quit should set quit")
	}
	if r := disp("/help"); len(r.lines) == 0 || !strings.Contains(join(r), "/run") {
		t.Errorf("/help lines: %v", r.lines)
	}
	if r := disp("/hooks"); len(r.lines) == 0 || !strings.Contains(join(r), "pre-commit:") {
		t.Errorf("/hooks should report live state: %v", r.lines)
	}
	if r := disp("/hooks pre-commit bogus"); !strings.Contains(join(r), "usage") {
		t.Errorf("/hooks bad action usage: %v", r.lines)
	}
	if r := disp("/settings"); len(r.lines) == 0 || !strings.Contains(join(r), "automation") {
		t.Errorf("/settings view: %v", r.lines)
	}
	if r := disp("/settings automation nonsense"); !strings.Contains(join(r), "must be manual") {
		t.Errorf("/settings rejects bad automation: %v", r.lines)
	}
	if r := disp("/settings automation auto"); !strings.Contains(join(r), "set to") {
		t.Errorf("/settings set automation: %v", r.lines)
	}
	if r := disp("/run"); !strings.Contains(join(r), "usage") {
		t.Errorf("/run no-arg usage: %v", r.lines)
	}
	if r := disp("/bogus"); !strings.Contains(join(r), "unknown command") {
		t.Errorf("unknown command: %v", r.lines)
	}
	if r := disp("/run comment-hygiene"); r.async != "run" || r.asyncS != "comment-hygiene" {
		t.Errorf("/run should be async: %+v", r)
	}
	// Free-text chat is retired (C5): a non-slash line is handled
	// synchronously with a dim hint, never an async pass.
	if r := disp("ask the model"); r.async != "" || !strings.Contains(join(r), "free-text chat is retired") {
		t.Errorf("free text should render the sync retirement hint, not an async pass: %+v", r)
	}
}

// --- engine ops (read-only / workspace-only, no new write path) ---

func TestOpQueueAndMemory_Empty(t *testing.T) {
	cfg := repo(t, true)
	st := newStyles(false)
	if got := opQueue(cfg, st, 80); !strings.Contains(strings.Join(got, "\n"), "empty") {
		t.Errorf("opQueue empty: %v", got)
	}
	if got := opMemory(cfg, st, 80); !strings.Contains(strings.Join(got, "\n"), "no facts") {
		t.Errorf("opMemory empty: %v", got)
	}
}

func TestOpMemory_MarksPinnedAndDisabled(t *testing.T) {
	cfg := repo(t, true)
	store, err := memory.Open(cfg)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)
	mk := func(name, desc string, typ memory.FactType, opts ...func(*memory.Fact)) {
		f := &memory.Fact{Name: name, Description: desc, Type: typ, Created: now, LastUsed: now}
		for _, o := range opts {
			o(f)
		}
		if err := store.Save(f); err != nil {
			t.Fatal(err)
		}
	}
	mk("plain-fact", "an active fact", memory.TypeProject)
	mk("muted-fact", "a disabled fact", memory.TypeFeedback, func(f *memory.Fact) { f.Disabled = true })
	mk("pinned-muted", "pinned and disabled", memory.TypeProject, func(f *memory.Fact) {
		f.Pin = true
		f.Disabled = true
	})

	st := newStyles(false)
	lines := opMemory(cfg, st, 80)
	got := strings.Join(lines, "\n")
	for _, want := range []string{"fact", "description", "type"} {
		if !strings.Contains(got, want) {
			t.Errorf("opMemory header missing %q:\n%s", want, got)
		}
	}
	findLine := func(needle string) string {
		for _, ln := range lines {
			if strings.Contains(ln, needle) {
				return ln
			}
		}
		return ""
	}
	cases := []struct{ slug, desc, marks string }{
		{"plain-fact", "an active fact", "project"},
		{"muted-fact", "a disabled fact", "[off]"},
		{"pinned-muted", "pinned and disabled", "[pinned] [off]"},
	}
	for _, c := range cases {
		ln := findLine(c.slug)
		if ln == "" {
			t.Errorf("opMemory missing slug %q:\n%s", c.slug, got)
			continue
		}
		if !strings.Contains(ln, c.desc) {
			t.Errorf("fact %q: description %q missing in %q", c.slug, c.desc, ln)
		}
		if !strings.Contains(ln, c.marks) {
			t.Errorf("fact %q: marks %q missing in %q", c.slug, c.marks, ln)
		}
	}
}

func TestOpGC_GuardThenRun(t *testing.T) {
	st := newStyles(false)
	if got := opGC(repo(t, false), st, 80); !strings.Contains(strings.Join(got, "\n"), "not initialised") {
		t.Errorf("opGC guard: %v", got)
	}
	if got := opGC(repo(t, true), st, 80); !strings.Contains(strings.Join(got, "\n"), "archived 0") {
		t.Errorf("opGC run: %v", got)
	}
}

func TestOpNew_GuardThenScaffold(t *testing.T) {
	st := newStyles(false)
	if got := opNew(repo(t, false), st, 80, "checks"); !strings.Contains(strings.Join(got, "\n"), "not initialised") {
		t.Errorf("opNew guard: %v", got)
	}
	cfg := repo(t, true)
	got := opNew(cfg, st, 80, "checks")
	if !strings.Contains(strings.Join(got, "\n"), "scaffolded") {
		t.Fatalf("opNew scaffold: %v", got)
	}
	if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "checks", "run")); err != nil {
		t.Errorf("scaffolded entry missing: %v", err)
	}
}

// --- model behaviour (headless: no Bubble Tea program loop) ---

func TestModel_HistoryNavigation(t *testing.T) {
	m := newModel(repo(t, true), "v")
	m.history = []string{"first", "second"}
	m.histPos = len(m.history)
	m.historyPrev()
	if m.ta.Value() != "second" {
		t.Fatalf("prev -> %q, want second", m.ta.Value())
	}
	m.historyPrev()
	if m.ta.Value() != "first" {
		t.Fatalf("prev -> %q, want first", m.ta.Value())
	}
	m.historyPrev() // clamps at oldest
	if m.ta.Value() != "first" {
		t.Fatalf("prev clamp -> %q, want first", m.ta.Value())
	}
	m.historyNext()
	m.historyNext() // back to the (empty) live draft
	if m.ta.Value() != "" {
		t.Fatalf("next -> %q, want empty draft", m.ta.Value())
	}
}

func TestModel_OverlayAndQuitKeys(t *testing.T) {
	m := newModel(repo(t, true), "v")

	// `?` on empty input opens the overlay; any key closes it.
	tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
	if !asModel(t, tm).overlay {
		t.Fatal("? should open the overlay")
	}
	tm, _ = asModel(t, tm).onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
	if asModel(t, tm).overlay {
		t.Fatal("any key should close the overlay")
	}

	// `q` on empty input quits.
	tm, cmd := newModel(repo(t, true), "v").onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
	if !asModel(t, tm).quitting || cmd == nil {
		t.Fatal("q on empty input should quit")
	}

	// Ctrl-C always quits.
	tm, cmd = newModel(repo(t, true), "v").onKey(tea.KeyMsg{Type: tea.KeyCtrlC})
	if !asModel(t, tm).quitting || cmd == nil {
		t.Fatal("Ctrl-C should quit")
	}
}

func TestModel_TabCompletesAndEscClears(t *testing.T) {
	m := newModel(repo(t, true), "v")
	m.ta.SetValue("/he")
	tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyTab})
	if v := asModel(t, tm).ta.Value(); v != "/help " {
		t.Fatalf("Tab completion -> %q, want %q", v, "/help ")
	}
	m2 := asModel(t, tm)
	tm, _ = m2.onKey(tea.KeyMsg{Type: tea.KeyEsc})
	if asModel(t, tm).ta.Value() != "" {
		t.Fatalf("Esc should clear input, got %q", asModel(t, tm).ta.Value())
	}
}

// --- v1.3.0 home view ---

func TestRenderHome_GoldenWidths(t *testing.T) {
	st := newStyles(false)
	for _, w := range []int{80, 120, 200} {
		t.Run("w"+strconv.Itoa(w), func(t *testing.T) {
			got := renderHome(w, st, "v1.5.0", tips[0])
			path := filepath.Join("testdata", "home_w"+strconv.Itoa(w)+".golden")
			if *updateGolden {
				if err := os.MkdirAll("testdata", 0o755); err != nil {
					t.Fatal(err)
				}
				if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
					t.Fatal(err)
				}
				return
			}
			raw, err := os.ReadFile(path)
			if err != nil {
				t.Fatalf("read golden %s (run with -update to create): %v", path, err)
			}
			// Git for Windows can rewrite LF to CRLF on checkout when
			// .gitattributes is not honoured (older clients, custom
			// configs). Normalise so a CRLF golden still matches.
			want := strings.ReplaceAll(string(raw), "\r\n", "\n")
			if want != got {
				t.Errorf("home view at width %d differs from %s — re-run with -update if intentional.\n--- want ---\n%s--- got ---\n%s", w, path, want, got)
			}
		})
	}
}

func TestRenderHome_ShowsTipAndVersion(t *testing.T) {
	st := newStyles(false)
	got := renderHome(80, st, "v1.5.0", tips[0])
	if !strings.Contains(got, tips[0]) {
		t.Errorf("home view should contain the tip %q:\n%s", tips[0], got)
	}
	if !strings.Contains(got, "v1.5.0") {
		t.Errorf("home view should contain the version line:\n%s", got)
	}
	// The home no longer lists commands; the / palette and ? overlay do.
	if strings.Contains(got, "run memory garbage collection") {
		t.Errorf("home view must not list commands (moved to / palette + ? overlay):\n%s", got)
	}
}

func TestPickTip_ReturnsMember(t *testing.T) {
	for i := 0; i < 50; i++ {
		got := pickTip()
		ok := false
		for _, tp := range tips {
			if tp == got {
				ok = true
				break
			}
		}
		if !ok {
			t.Fatalf("pickTip returned %q, not a member of tips", got)
		}
	}
}

func TestView_NeverContainsHomeBlock(t *testing.T) {
	logoTop := strings.Split(eecoLogo, "\n")[0]
	m := newModel(repo(t, false), "v")
	if strings.Contains(m.View(), logoTop) {
		t.Fatalf("View must not embed the home block (printed once via scrollback):\n%s", m.View())
	}
	m.ta.SetValue("/")
	if strings.Contains(m.View(), logoTop) {
		t.Errorf("logo must not appear when typing:\n%s", m.View())
	}
	m.ta.SetValue("")
	if strings.Contains(m.View(), logoTop) {
		t.Errorf("logo must not re-render on clear (no logo stacking):\n%s", m.View())
	}
	m.running = true
	if strings.Contains(m.View(), logoTop) {
		t.Errorf("logo must not appear while a background op is running:\n%s", m.View())
	}
}

func TestUpdate_PrintsHomeOnceOnFirstWindowSize(t *testing.T) {
	m := newModel(repo(t, false), "v")
	if m.homePrinted {
		t.Fatal("homePrinted must start false")
	}
	tm, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
	got := asModel(t, tm)
	if !got.homePrinted {
		t.Fatal("first WindowSizeMsg must mark home printed")
	}
	if got.width != 80 {
		t.Fatalf("width not stored: %d", got.width)
	}
	if cmd == nil {
		t.Fatal("first WindowSizeMsg must emit a print command")
	}
	// Second WindowSizeMsg (e.g. terminal resize) must not re-print the
	// home block — that would re-introduce the stacking the operator
	// flagged.
	tm2, cmd2 := got.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
	got2 := asModel(t, tm2)
	if got2.width != 100 {
		t.Fatalf("resize width not stored: %d", got2.width)
	}
	if cmd2 != nil {
		t.Fatal("second WindowSizeMsg must not re-print the home block")
	}
}

func TestCommandIndex_MatchesSlashCommands(t *testing.T) {
	if len(commandIndex) != len(slashCommands) {
		t.Fatalf("len mismatch: commandIndex=%d slashCommands=%d", len(commandIndex), len(slashCommands))
	}
	for i, e := range commandIndex {
		if e.name != slashCommands[i] {
			t.Errorf("index %d: commandIndex=%q slashCommands=%q", i, e.name, slashCommands[i])
		}
	}
}

func TestModel_AsyncResultGenerationGuard(t *testing.T) {
	m := newModel(repo(t, true), "v")
	m.gen = 5
	m.running = true
	// A stale result (interrupted: gen advanced) is dropped, leaving the
	// running flag untouched for the still-current operation.
	tm, _ := m.Update(asyncResultMsg{gen: 4, lines: []string{"stale"}})
	if !asModel(t, tm).running {
		t.Fatal("stale result must not clear running")
	}
	// The matching result is accepted and clears running.
	tm, _ = m.Update(asyncResultMsg{gen: 5, lines: []string{"ok"}, isRun: true, summary: "run x: ok"})
	got := asModel(t, tm)
	if got.running || got.lastRun != "run x: ok" {
		t.Fatalf("current result not applied: running=%v lastRun=%q", got.running, got.lastRun)
	}
}

// --- v1.3.0 slash-command palette ---

func TestPaletteOpen(t *testing.T) {
	m := newModel(repo(t, true), "v")
	cases := []struct {
		in   string
		want bool
	}{
		{"/", true},      // bare slash opens
		{"/me", true},    // command token still being typed
		{"/run ", false}, // committed command + space -> argument mode
		{"", false},      // empty never opens
		{"hello", false}, // plain text never opens
	}
	for _, c := range cases {
		m.ta.SetValue(c.in)
		if got := m.paletteOpen(); got != c.want {
			t.Errorf("paletteOpen(%q)=%v, want %v", c.in, got, c.want)
		}
	}
}

func TestPaletteItems_Filter(t *testing.T) {
	m := newModel(repo(t, true), "v")

	m.ta.SetValue("/")
	if got := len(m.paletteItems()); got != len(commandIndex) {
		t.Fatalf("bare slash should list all commands; got %d want %d", got, len(commandIndex))
	}

	m.ta.SetValue("/h")
	items := m.paletteItems()
	if len(items) != 2 || items[0].name != "/help" || items[1].name != "/hooks" {
		t.Fatalf("/h should prefix-match /help,/hooks; got %v", items)
	}

	m.ta.SetValue("/zz")
	if got := len(m.paletteItems()); got != 0 {
		t.Fatalf("/zz should match nothing; got %d", got)
	}
}

func TestPalette_CursorMoveClampAndReset(t *testing.T) {
	m := newModel(repo(t, true), "v")
	m.ta.SetValue("/")
	m.ta.CursorEnd()
	n := len(m.paletteItems())

	// Down past the bottom clamps at the last row.
	for i := 0; i < n+3; i++ {
		tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyDown})
		m = asModel(t, tm)
	}
	if m.pal.cursor != n-1 {
		t.Fatalf("Down clamp: cursor=%d want %d", m.pal.cursor, n-1)
	}
	// Up past the top clamps at 0.
	for i := 0; i < n+3; i++ {
		tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp})
		m = asModel(t, tm)
	}
	if m.pal.cursor != 0 {
		t.Fatalf("Up clamp: cursor=%d want 0", m.pal.cursor)
	}

	// Advance, then change the filter by typing -> cursor snaps back to 0.
	tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyDown})
	m = asModel(t, tm)
	if m.pal.cursor == 0 {
		t.Fatal("Down should have advanced the cursor before the filter test")
	}
	tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")})
	m = asModel(t, tm)
	if m.ta.Value() != "/h" {
		t.Fatalf("typing should filter the input; ta=%q", m.ta.Value())
	}
	if m.pal.cursor != 0 {
		t.Fatalf("a filter change must reset the cursor to 0; cursor=%d", m.pal.cursor)
	}
}

func TestPalette_AcceptFillsAndCloses(t *testing.T) {
	// Both Tab and Enter accept the highlighted row; neither submits.
	for _, key := range []tea.KeyType{tea.KeyTab, tea.KeyEnter} {
		m := newModel(repo(t, true), "v")
		m.ta.SetValue("/h") // -> /help,/hooks; cursor 0 = /help
		m.ta.CursorEnd()
		tm, _ := m.onKey(tea.KeyMsg{Type: key})
		got := asModel(t, tm)
		if got.ta.Value() != "/help " {
			t.Fatalf("%v accept: ta=%q want %q", key, got.ta.Value(), "/help ")
		}
		if got.paletteOpen() {
			t.Fatalf("%v accept must close the palette", key)
		}
		if got.quitting {
			t.Fatalf("%v accept must not submit/quit", key)
		}
	}
}

func TestPalette_KeyRouting(t *testing.T) {
	// Palette open: Up/Down move the highlight, never the command history.
	open := newModel(repo(t, true), "v")
	open.history = []string{"old"}
	open.histPos = len(open.history)
	open.ta.SetValue("/")
	open.ta.CursorEnd()
	tm, _ := open.onKey(tea.KeyMsg{Type: tea.KeyDown})
	if v := asModel(t, tm).ta.Value(); v != "/" {
		t.Fatalf("palette-open Down must not navigate history; ta=%q", v)
	}
	tm, _ = open.onKey(tea.KeyMsg{Type: tea.KeyUp})
	if v := asModel(t, tm).ta.Value(); v != "/" {
		t.Fatalf("palette-open Up must not navigate history; ta=%q", v)
	}

	// Palette closed: Up still browses history.
	closed := newModel(repo(t, true), "v")
	closed.history = []string{"old"}
	closed.histPos = len(closed.history)
	tm, _ = closed.onKey(tea.KeyMsg{Type: tea.KeyUp})
	if v := asModel(t, tm).ta.Value(); v != "old" {
		t.Fatalf("palette-closed Up must browse history; ta=%q", v)
	}
}

func TestRenderPalette_Content(t *testing.T) {
	st := newStyles(false) // plain styles so assertions read raw text
	items := []cmdEntry{
		{"/gc", "run memory garbage collection"},
		{"/help", "command and key reference"},
	}
	out := renderPalette(items, 0, 80, st)
	for _, w := range []string{"/gc", "run memory garbage collection", "/help", "command and key reference"} {
		if !strings.Contains(out, w) {
			t.Errorf("renderPalette missing %q:\n%s", w, out)
		}
	}
	if !strings.Contains(out, "›") {
		t.Errorf("the selected row should carry the › marker:\n%s", out)
	}
	if got := renderPalette(nil, 0, 80, st); !strings.Contains(got, "no match") {
		t.Errorf("an empty palette should render \"no match\"; got %q", got)
	}
}

func TestRenderPalette_ScrollKeepsSelectionVisible(t *testing.T) {
	st := newStyles(false)
	// More items than the row cap: the highlighted row must stay visible
	// (regression for the cursor-past-the-window overflow bug).
	items := make([]cmdEntry, paletteMaxRows+4)
	for i := range items {
		items[i] = cmdEntry{name: fmt.Sprintf("/cmd%02d", i), purpose: fmt.Sprintf("does thing %d", i)}
	}
	last := len(items) - 1
	out := renderPalette(items, last, 80, st)
	sel := items[last]
	if !strings.Contains(out, sel.name) || !strings.Contains(out, sel.purpose) {
		t.Errorf("selected (last) row %q must be visible when scrolled:\n%s", sel.name, out)
	}
	// The marker must sit on the selected row's line, not a stale top row.
	for _, ln := range strings.Split(out, "\n") {
		if strings.Contains(ln, "›") && !strings.Contains(ln, sel.name) {
			t.Errorf("marker on the wrong row %q (selected %q):\n%s", ln, sel.name, out)
		}
	}
	if !strings.Contains(out, "more") {
		t.Errorf("hidden rows should surface a \"+N more\" line:\n%s", out)
	}
}

func TestView_PaletteOpenVsClosed(t *testing.T) {
	m := newModel(repo(t, false), "v")
	m.width = 80

	m.ta.SetValue("/")
	open := m.View()
	if !strings.Contains(open, "/help") || !strings.Contains(open, "command and key reference") {
		t.Errorf("open palette View should list commands and purposes:\n%s", open)
	}

	// Closed: no command rows leak into the live region.
	m.ta.SetValue("")
	if closed := m.View(); strings.Contains(closed, "command and key reference") {
		t.Errorf("closed palette must not render rows:\n%s", closed)
	}
}

// --- v1.4.0 multi-line composer + animated spinner ---

func TestModel_EnterSubmitsAltEnterNewline(t *testing.T) {
	// Plain Enter on a non-empty line submits and clears the composer. Free
	// text is handled synchronously now (chat retired in C5), so it prints a
	// hint without starting a background op.
	m := newModel(repo(t, true), "v")
	m.ta.SetValue("summarise the project")
	tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyEnter})
	got := asModel(t, tm)
	if got.running {
		t.Fatal("free text must not start a background op (chat retired)")
	}
	if got.ta.Value() != "" {
		t.Fatalf("submit should clear the composer; got %q", got.ta.Value())
	}

	// Alt+Enter inserts a newline rather than submitting.
	m2 := newModel(repo(t, true), "v")
	m2.ta.SetValue("line one")
	m2.ta.CursorEnd()
	tm2, _ := m2.onKey(tea.KeyMsg{Type: tea.KeyEnter, Alt: true})
	got2 := asModel(t, tm2)
	if got2.running || got2.quitting {
		t.Fatal("Alt+Enter must not submit")
	}
	if !strings.Contains(got2.ta.Value(), "\n") {
		t.Fatalf("Alt+Enter should insert a newline; got %q", got2.ta.Value())
	}
}

func TestModel_CtrlJInsertsNewline(t *testing.T) {
	// Ctrl+J is the literal-LF fallback for terminals that swallow Alt+Enter.
	m := newModel(repo(t, true), "v")
	m.ta.SetValue("abc")
	m.ta.CursorEnd()
	tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyCtrlJ})
	got := asModel(t, tm)
	if got.running || got.quitting {
		t.Fatal("Ctrl+J must not submit or quit")
	}
	if !strings.Contains(got.ta.Value(), "\n") {
		t.Fatalf("Ctrl+J should insert a newline; got %q", got.ta.Value())
	}
}

func TestModel_UpDownLineBoundaryRouting(t *testing.T) {
	m := newModel(repo(t, true), "v")
	m.ta.SetWidth(80)
	m.history = []string{"recalled"}
	m.histPos = len(m.history)
	m.ta.SetValue("top\nmiddle\nbottom") // cursor lands on the last line
	m.reflowHeight()

	// Move the cursor to a middle line; Up there moves the cursor, it does
	// not recall history.
	m.ta.CursorUp()
	if m.ta.Line() != 1 {
		t.Fatalf("precondition: cursor should be on the middle line; Line=%d", m.ta.Line())
	}
	tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp})
	got := asModel(t, tm)
	if got.ta.Value() != "top\nmiddle\nbottom" {
		t.Fatalf("Up off the boundary must not recall history; value=%q", got.ta.Value())
	}
	if got.ta.Line() != 0 {
		t.Fatalf("Up off the boundary should move the cursor up; Line=%d want 0", got.ta.Line())
	}

	// Up on the first line (top boundary) recalls history.
	tm, _ = got.onKey(tea.KeyMsg{Type: tea.KeyUp})
	if v := asModel(t, tm).ta.Value(); v != "recalled" {
		t.Fatalf("Up at line 0 should recall history; got %q", v)
	}

	// Down on the last line (bottom boundary) restores the saved multi-line
	// draft (history forward past the newest entry).
	m2 := newModel(repo(t, true), "v")
	m2.ta.SetWidth(80)
	m2.history = []string{"recalled"}
	m2.histPos = len(m2.history)
	m2.ta.SetValue("a\nb")
	m2.reflowHeight()
	m2.ta.CursorUp() // to line 0 so the recall fires
	tm2, _ := m2.onKey(tea.KeyMsg{Type: tea.KeyUp})
	got2 := asModel(t, tm2)
	if got2.ta.Value() != "recalled" {
		t.Fatalf("setup recall failed; got %q", got2.ta.Value())
	}
	tm2, _ = got2.onKey(tea.KeyMsg{Type: tea.KeyDown})
	if v := asModel(t, tm2).ta.Value(); v != "a\nb" {
		t.Fatalf("Down at the bottom should restore the multi-line draft; got %q", v)
	}
}

func TestModel_SingleLineHistoryRegression(t *testing.T) {
	// A single-line draft has Line()==0==LineCount()-1, so Up and Down route
	// to history exactly as before the multi-line composer.
	m := newModel(repo(t, true), "v")
	m.history = []string{"first", "second"}
	m.histPos = len(m.history)

	tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp})
	m = asModel(t, tm)
	if m.ta.Value() != "second" {
		t.Fatalf("Up -> %q, want second", m.ta.Value())
	}
	tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyUp})
	m = asModel(t, tm)
	if m.ta.Value() != "first" {
		t.Fatalf("Up -> %q, want first", m.ta.Value())
	}
	tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyDown})
	m = asModel(t, tm)
	if m.ta.Value() != "second" {
		t.Fatalf("Down -> %q, want second", m.ta.Value())
	}
}

func TestModel_ReflowHeight(t *testing.T) {
	m := newModel(repo(t, true), "v")
	m.ta.SetWidth(80)

	m.ta.SetValue("a\nb\nc")
	m.reflowHeight()
	if h := m.ta.Height(); h != 3 {
		t.Fatalf("a 3-line draft should reflow to 3 rows; Height=%d", h)
	}
	m.ta.SetValue("")
	m.reflowHeight()
	if h := m.ta.Height(); h != 1 {
		t.Fatalf("a cleared draft should shrink to 1 row; Height=%d", h)
	}
	// More lines than the cap clamp at inputMaxRows (then the box scrolls).
	m.ta.SetValue(strings.Repeat("x\n", inputMaxRows+5))
	m.reflowHeight()
	if h := m.ta.Height(); h != inputMaxRows {
		t.Fatalf("a long draft should clamp at inputMaxRows=%d; Height=%d", inputMaxRows, h)
	}
}

func TestModel_SpinnerTickLifecycle(t *testing.T) {
	m := newModel(repo(t, true), "v")
	// Running: a tick advances the spinner and re-arms the loop.
	m.running = true
	_, cmd := m.Update(spinner.TickMsg{})
	if cmd == nil {
		t.Fatal("a spinner tick while running must return a follow-up command")
	}
	// Idle: the tick loop dies so the spinner cannot spin forever.
	m.running = false
	_, cmd = m.Update(spinner.TickMsg{})
	if cmd != nil {
		t.Fatal("a spinner tick while idle must not re-arm the loop")
	}
}

func TestModel_SpinnerInView(t *testing.T) {
	m := newModel(repo(t, true), "v")
	m.width = 80

	idle := m.View()
	if strings.Contains(idle, "working") {
		t.Errorf("idle footer must not show the working label:\n%s", idle)
	}
	if strings.ContainsAny(idle, miniDotFrames) {
		t.Errorf("idle footer must not show a spinner frame:\n%s", idle)
	}

	m.running = true
	run := m.View()
	if !strings.Contains(run, "working (Esc to interrupt)") {
		t.Errorf("running footer should show the working label:\n%s", run)
	}
	if !strings.ContainsAny(run, miniDotFrames) {
		t.Errorf("running footer should show a MiniDot spinner frame:\n%s", run)
	}
}

func TestModel_AltEnterPaletteOpenInsertsNewline(t *testing.T) {
	// With the palette open ("/he"), Alt+Enter inserts a newline (closing the
	// palette into free text), never accepts the highlighted command —
	// consistent with Ctrl+J, which already falls through.
	m := newModel(repo(t, true), "v")
	m.ta.SetValue("/he")
	m.ta.CursorEnd()
	if !m.paletteOpen() {
		t.Fatal("precondition: palette should be open for /he")
	}
	tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyEnter, Alt: true})
	got := asModel(t, tm)
	if !strings.Contains(got.ta.Value(), "\n") {
		t.Fatalf("Alt+Enter with palette open should insert a newline; got %q", got.ta.Value())
	}
	if got.ta.Value() == "/help " {
		t.Fatal("Alt+Enter must not accept the palette selection")
	}
	if got.paletteOpen() {
		t.Fatal("a newline must close the palette")
	}
}

func TestPaletteClosedOnMultiline(t *testing.T) {
	m := newModel(repo(t, true), "v")
	// A leading slash but containing a newline is free text, not a command.
	m.ta.SetValue("/foo\nbar")
	if m.paletteOpen() {
		t.Error("a multi-line value must close the palette even with a leading slash")
	}
	// A whitespace control char (tab) likewise closes it.
	m.ta.SetValue("/foo\tbar")
	if m.paletteOpen() {
		t.Error("a tab in the value must close the palette")
	}
}

// --- H1.4: dispatch / op-branch + async-guard depth (no seam, no prod code) ---

// opRun: a bad operator attribution pattern fails NewDetector before any
// workflow runs, so the run reports the compile error and never spends AI.
func TestOpRun_DetectorErrorBadAttributionPattern(t *testing.T) {
	cfg := repo(t, true)
	cfg.AttributionPatterns = []string{"("} // unterminated group → compile error
	st := newStyles(false)
	summary, lines, _ := opRun(cfg, st, 80, "comment-hygiene", false)
	if !strings.Contains(summary, "run comment-hygiene:") {
		t.Errorf("a detector compile error should surface in the summary: %q", summary)
	}
	if !strings.Contains(strings.Join(lines, "\n"), "run comment-hygiene") {
		t.Errorf("the error section should carry the run title:\n%s", strings.Join(lines, "\n"))
	}
}

// opRun: an unknown name misses the registry and errors through ScriptRun.
func TestOpRun_ScriptRunErrorUnknownWorkflow(t *testing.T) {
	cfg := repo(t, true)
	st := newStyles(false)
	summary, lines, _ := opRun(cfg, st, 80, "no-such-wf", false)
	if !strings.Contains(summary, "run no-such-wf:") {
		t.Errorf("an unknown workflow should error through ScriptRun: %q", summary)
	}
	if !strings.Contains(strings.Join(lines, "\n"), "run no-such-wf") {
		t.Errorf("the error section should carry the run title:\n%s", strings.Join(lines, "\n"))
	}
}

// opRun: the comment-hygiene builtin runs on the temp tree (filesystem walk,
// no real git, no AI) and reports a clean, no-findings success.
func TestOpRun_CommentHygieneCleanTreeNoFindings(t *testing.T) {
	cfg := repo(t, true)
	st := newStyles(false)
	summary, lines, _ := opRun(cfg, st, 80, "comment-hygiene", false)
	got := strings.Join(lines, "\n")
	if !strings.Contains(got, "no findings") {
		t.Errorf("a clean tree should render the no-findings body:\n%s", got)
	}
	if !strings.Contains(summary, "run comment-hygiene:") {
		t.Errorf("the summary should describe the run: %q", summary)
	}
}

// runNames lists the builtins, tolerates a nil config, and surfaces a
// workspace-scaffolded workflow (the e.IsDir() arm over workflows/).
func TestRunNames_BuiltinsWorkspaceAndNilConfig(t *testing.T) {
	cfg := repo(t, true)
	base := runNames(cfg)
	for _, want := range []string{"comment-hygiene", "leak-guard"} {
		if !slices.Contains(base, want) {
			t.Errorf("runNames should list builtin %q; got %v", want, base)
		}
	}
	if got := runNames(nil); !slices.Contains(got, "comment-hygiene") {
		t.Errorf("runNames(nil) should list builtins; got %v", got)
	}
	st := newStyles(false)
	if out := opNew(cfg, st, 80, "demo"); !strings.Contains(strings.Join(out, "\n"), "scaffolded") {
		t.Fatalf("precondition: opNew should scaffold demo: %v", out)
	}
	if got := runNames(cfg); !slices.Contains(got, "demo") {
		t.Errorf("runNames should include the scaffolded workflow; got %v", got)
	}
}

// startAsync: invoke the returned tea.Cmd for each dispatchResult branch and
// assert the asyncResultMsg it produces (the three never-invoked arms + the
// default fall-through).
func TestModel_StartAsyncBranchesInvoked(t *testing.T) {
	ctx := context.Background()
	const gen = 7
	invoke := func(m model, res dispatchResult) asyncResultMsg {
		t.Helper()
		m.width = 80
		cmd := m.startAsync(ctx, gen, res)
		msg, ok := cmd().(asyncResultMsg)
		if !ok {
			t.Fatalf("startAsync cmd should return an asyncResultMsg")
		}
		if msg.gen != gen {
			t.Errorf("result gen = %d, want %d", msg.gen, gen)
		}
		return msg
	}

	// gc: runs opGC, never a run.
	if gcMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: "gc"}); gcMsg.isRun {
		t.Error("gc branch must not flag isRun")
	}
	// run: runs opRun, isRun with a summary.
	runMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: "run", asyncS: "comment-hygiene"})
	if !runMsg.isRun || !strings.Contains(runMsg.summary, "run comment-hygiene") {
		t.Errorf("run branch should set isRun + summary: %+v", runMsg)
	}
	// default: an unrecognised async yields a bare gen result. (Free-text
	// chat was retired in C5, so there is no longer a "free" async branch.)
	defMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: ""})
	if defMsg.isRun || len(defMsg.lines) != 0 {
		t.Errorf("default branch should return a bare gen result: %+v", defMsg)
	}
}

// onKey: Esc while an op is running cancels it, bumps the generation to
// invalidate the in-flight result, and clears the running/cancel state —
// distinct from the Esc-clears-input arm when idle.
func TestModel_EscInterruptsRunningOp(t *testing.T) {
	m := newModel(repo(t, true), "v")
	m.running = true
	m.gen = 4
	cancelled := false
	m.cancel = func() { cancelled = true }
	tm, cmd := m.onKey(tea.KeyMsg{Type: tea.KeyEsc})
	got := asModel(t, tm)
	if got.gen != 5 {
		t.Errorf("Esc while running should bump gen; gen=%d want 5", got.gen)
	}
	if got.running {
		t.Error("Esc while running should clear running")
	}
	if got.cancel != nil {
		t.Error("Esc while running should clear the cancel func")
	}
	if !cancelled {
		t.Error("Esc while running should invoke the cancel func")
	}
	if cmd == nil {
		t.Error("Esc while running should emit the interrupted notice")
	}
}

// opSettings: the uninitialised guard plus the per-key validation rejects.
func TestOpSettings_GuardAndValidationErrors(t *testing.T) {
	st := newStyles(false)
	if got := strings.Join(opSettings(repo(t, false), st, 80, []string{"automation", "auto"}), "\n"); !strings.Contains(got, "not initialised") {
		t.Errorf("uninitialised settings should guard:\n%s", got)
	}
	cfg := repo(t, true)
	if got := strings.Join(opSettings(cfg, st, 80, []string{"ai_budget", "-1"}), "\n"); !strings.Contains(got, "non-negative") {
		t.Errorf("a negative ai_budget should be rejected:\n%s", got)
	}
	// One token → val == "" (a direct call: parseInput would drop the empty
	// trailing token, so build args by hand).
	if got := strings.Join(opSettings(cfg, st, 80, []string{"ai_command"}), "\n"); !strings.Contains(got, "needs an argv") {
		t.Errorf("an empty ai_command should be rejected:\n%s", got)
	}
	if got := strings.Join(opSettings(cfg, st, 80, []string{"bogus", "x"}), "\n"); !strings.Contains(got, "unknown key") {
		t.Errorf("an unknown key should render usage:\n%s", got)
	}
}

// opSettings: a configured provider renders the "configured" view.
func TestOpSettings_ProviderConfiguredView(t *testing.T) {
	cfg := repo(t, true)
	cfg.AICommand = []string{"x"}
	st := newStyles(false)
	got := strings.Join(opSettings(cfg, st, 80, nil), "\n")
	if !strings.Contains(got, "configured") || strings.Contains(got, "every AI pass is parked") {
		t.Errorf("a configured provider should render the configured view:\n%s", got)
	}
}

// opSettings: a WriteLocalKeys failure (config.local is a directory →
// ReadFile errors) wraps as a settings error. Assert the wrap text, never the
// errno (Windows-safe).
func TestOpSettings_WriteLocalKeysErrorWraps(t *testing.T) {
	cfg := repo(t, true)
	localPath := filepath.Join(cfg.Workspace, config.LocalFilename)
	if err := os.RemoveAll(localPath); err != nil {
		t.Fatal(err)
	}
	if err := os.Mkdir(localPath, 0o755); err != nil {
		t.Fatal(err)
	}
	st := newStyles(false)
	// A valid key (validated before the write) so the failure is the write.
	got := strings.Join(opSettings(cfg, st, 80, []string{"automation", "propose"}), "\n")
	if !strings.Contains(got, "settings:") {
		t.Errorf("a WriteLocalKeys failure should wrap as a settings error:\n%s", got)
	}
}

// opHooks: toggling the pre-commit hook on then off reports success. Use the
// hooks.PreCommit constant (== "pre-commit"); a CamelCase literal falls
// through to usage. Assert message text only, never the file mode.
func TestOpHooks_PreCommitToggleSucceeds(t *testing.T) {
	cfg := repo(t, true)
	st := newStyles(false)
	if on := strings.Join(opHooks(cfg, st, 80, []string{hooks.PreCommit, "on"}), "\n"); !strings.Contains(on, "hooks:") {
		t.Errorf("pre-commit on should report success:\n%s", on)
	}
	if off := strings.Join(opHooks(cfg, st, 80, []string{hooks.PreCommit, "off"}), "\n"); !strings.Contains(off, "hooks:") {
		t.Errorf("pre-commit off should report success:\n%s", off)
	}
}

// opHooks: toggling session-start on then off reports success. The settings
// path must be set first, else EnableSessionStart returns
// ErrSessionNotConfigured.
func TestOpHooks_SessionStartToggleSucceeds(t *testing.T) {
	cfg := repo(t, true)
	cfg.SessionSettingsPath = filepath.Join(t.TempDir(), "settings.json")
	st := newStyles(false)
	if on := strings.Join(opHooks(cfg, st, 80, []string{hooks.SessionStart, "on"}), "\n"); !strings.Contains(on, "hooks:") {
		t.Errorf("session-start on should report success:\n%s", on)
	}
	if off := strings.Join(opHooks(cfg, st, 80, []string{hooks.SessionStart, "off"}), "\n"); !strings.Contains(off, "hooks:") {
		t.Errorf("session-start off should report success:\n%s", off)
	}
}

// opHooks: wrong arity renders usage; session-start on with nothing configured
// reaches the error arm via ErrSessionNotConfigured.
func TestOpHooks_ArgArityAndConfigErrorArms(t *testing.T) {
	st := newStyles(false)
	usage := strings.Join(opHooks(repo(t, true), st, 80, []string{"a", "b", "c"}), "\n")
	if !strings.Contains(usage, "usage") {
		t.Errorf("a 3-arg hooks call should render usage:\n%s", usage)
	}
	errOut := strings.Join(opHooks(repo(t, true), st, 80, []string{hooks.SessionStart, "on"}), "\n")
	if !strings.Contains(errOut, "session-start not configured") {
		t.Errorf("session-start on (unconfigured) should hit the error arm:\n%s", errOut)
	}
}

// opQueue: a non-empty queue file renders the open-count and the item body.
func TestOpQueue_NonEmptyBody(t *testing.T) {
	cfg := repo(t, true)
	stateDir := filepath.Join(cfg.Workspace, "state")
	if err := os.MkdirAll(stateDir, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(
		filepath.Join(stateDir, "queue.md"),
		[]byte("# eeco queue\n\n- [ ] **k** — t _(p, 2026-05-19)_\n"),
		0o644,
	); err != nil {
		t.Fatal(err)
	}
	st := newStyles(false)
	got := strings.Join(opQueue(cfg, st, 80), "\n")
	if !strings.Contains(got, "open") {
		t.Errorf("a non-empty queue should report an open count:\n%s", got)
	}
	if !strings.Contains(got, "**k**") {
		t.Errorf("a non-empty queue should render the item body:\n%s", got)
	}
}

// opGC: a missing-ref reference fact is archived (clock-free: the missing-ref
// trigger fires before any staleness check); an ordinary active fact is kept
// and skipped from the action body. A disabled fact would map to "kept", so a
// missing-ref reference fact is used for the non-kept format line.
func TestOpGC_FormatsArchivedAndKeptActions(t *testing.T) {
	cfg := repo(t, true)
	store, err := memory.Open(cfg)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)
	refFact := &memory.Fact{
		Name: "dangling-ref", Description: "points nowhere",
		Type: memory.TypeReference, Ref: "no/such/file",
		Created: now, LastUsed: now,
	}
	if err := store.Save(refFact); err != nil {
		t.Fatal(err)
	}
	keepFact := &memory.Fact{
		Name: "keeper", Description: "still relevant",
		Type: memory.TypeProject, Created: now, LastUsed: now,
	}
	if err := store.Save(keepFact); err != nil {
		t.Fatal(err)
	}
	st := newStyles(false)
	got := strings.Join(opGC(cfg, st, 80), "\n")
	if !strings.Contains(got, "archived 1") {
		t.Errorf("opGC should report one archived fact:\n%s", got)
	}
	if !strings.Contains(got, "dangling-ref") || !strings.Contains(got, "ref missing") {
		t.Errorf("opGC should format the archived action line:\n%s", got)
	}
	if strings.Contains(got, "keeper") {
		t.Errorf("a kept fact must be skipped from the action body:\n%s", got)
	}
}

// opMemory: a file sitting where the memory directory should be makes
// memory.Open's MkdirAll fail (not-a-directory), surfacing a memory error.
// Built on repo(t,false) so Init does not pre-create memory/.
func TestOpMemory_OpenErrorOnFileAtMemoryPath(t *testing.T) {
	cfg := repo(t, false)
	if err := os.MkdirAll(cfg.Workspace, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(cfg.Workspace, "memory"), []byte("not a dir\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	st := newStyles(false)
	if got := strings.Join(opMemory(cfg, st, 80), "\n"); !strings.Contains(got, "memory:") {
		t.Errorf("a file at the memory path should surface an Open error:\n%s", got)
	}
}

// opMemory: a malformed fact file aborts LoadAll, surfacing a memory error
// rather than the empty-store section.
func TestOpMemory_LoadAllErrorOnMalformedFact(t *testing.T) {
	cfg := repo(t, true)
	if err := os.WriteFile(filepath.Join(cfg.Workspace, "memory", "not-a-fact.md"), []byte("garbage\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	st := newStyles(false)
	got := strings.Join(opMemory(cfg, st, 80), "\n")
	if !strings.Contains(got, "memory:") {
		t.Errorf("a malformed fact should surface a LoadAll error:\n%s", got)
	}
	if strings.Contains(got, "no facts") {
		t.Errorf("the error path must not fall through to the empty section:\n%s", got)
	}
}

// opNew: a name the scaffolder rejects surfaces a new error.
func TestOpNew_ScaffoldErrorBadName(t *testing.T) {
	st := newStyles(false)
	got := strings.Join(opNew(repo(t, true), st, 80, "Bad Name"), "\n")
	if !strings.Contains(got, "new:") {
		t.Errorf("a bad workflow name should surface a scaffold error:\n%s", got)
	}
	if strings.Contains(got, "scaffolded") {
		t.Errorf("a rejected name must not report success:\n%s", got)
	}
}

// dispatch: the genuinely-uncovered queue/memory/gc/new arms (the
// quit/clear/help/hooks/settings/run/bogus/free arms are covered elsewhere).
func TestDispatch_QueueMemoryGCNewArms(t *testing.T) {
	cfg := repo(t, true)
	st := newStyles(false)
	disp := func(input string) dispatchResult { return dispatch(cfg, st, 80, parseInput(input)) }
	join := func(r dispatchResult) string { return strings.Join(r.lines, "\n") }

	if r := disp("/queue"); len(r.lines) == 0 || !strings.Contains(join(r), "queue") {
		t.Errorf("/queue should render the queue section: %v", r.lines)
	}
	if r := disp("/memory"); len(r.lines) == 0 || !strings.Contains(join(r), "memory") {
		t.Errorf("/memory should render the memory section: %v", r.lines)
	}
	if r := disp("/gc"); r.async != "gc" {
		t.Errorf("/gc should dispatch the async gc op: %+v", r)
	}
	if r := disp("/new demo"); !strings.Contains(join(r), "scaffolded") {
		t.Errorf("/new <name> should scaffold: %v", r.lines)
	}
	if r := disp("/new"); !strings.Contains(join(r), "usage") {
		t.Errorf("/new no-arg should render usage: %v", r.lines)
	}
}

// Init returns the textarea blink command.
func TestModel_InitReturnsBlink(t *testing.T) {
	if cmd := newModel(repo(t, true), "v").Init(); cmd == nil {
		t.Fatal("Init must return a non-nil (blink) command")
	}
}

// truncate: the three cut arms plus the passthrough.
func TestTruncate_Table(t *testing.T) {
	cases := []struct {
		s    string
		w    int
		want string
	}{
		{"x", 0, ""},         // w <= 0
		{"ab", 1, "a"},       // w <= 1, must cut
		{"abcdef", 3, "ab…"}, // cut with ellipsis
		{"ab", 5, "ab"},      // len(r) <= w passthrough
	}
	for _, c := range cases {
		if got := truncate(c.s, c.w); got != c.want {
			t.Errorf("truncate(%q,%d) = %q, want %q", c.s, c.w, got, c.want)
		}
	}
}

// View: the quitting short-circuit and the overlay branch (behavioral, not
// golden).
func TestView_QuittingAndOverlay(t *testing.T) {
	q := newModel(repo(t, true), "v")
	q.quitting = true
	if q.View() != "" {
		t.Errorf("a quitting model renders an empty view; got %q", q.View())
	}
	o := newModel(repo(t, true), "v")
	o.overlay = true
	o.width = 80
	if !strings.Contains(o.View(), "press any key to close") {
		t.Errorf("an open overlay should render the close hint:\n%s", o.View())
	}
}