package modes import ( "errors" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/patriceckhart/zot/packages/agent/swarm" "github.com/patriceckhart/zot/packages/provider" "github.com/patriceckhart/zot/packages/tui" ) // staticSnapshots returns a deterministic snapshot slice for the // dialog to render. func staticSnapshots(rows ...swarm.AgentSnapshot) func() []swarm.AgentSnapshot { return func() []swarm.AgentSnapshot { out := make([]swarm.AgentSnapshot, len(rows)) copy(out, rows) return out } } func TestSwarmDialogEmptyState(t *testing.T) { d := newSwarmDialog() d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") lines := d.Render(tui.Theme{}, 80) joined := strings.Join(lines, "\n") if !strings.Contains(joined, "no agents") { t.Fatalf("empty state missing hint:\n%s", joined) } if !strings.Contains(joined, "press n") { t.Fatalf("empty state should advertise n shortcut:\n%s", joined) } } func TestSwarmDialogRendersRows(t *testing.T) { now := time.Now() rows := []swarm.AgentSnapshot{ {ID: "alpha-1", Task: "fix login", Status: swarm.StatusRunning, Activity: "editing file", Started: now}, {ID: "beta-2", Task: "write tests", Status: swarm.StatusDone, Activity: "done", Started: now}, } d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") out := strings.Join(d.Render(tui.Theme{}, 100), "\n") for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} { if !strings.Contains(out, want) { t.Fatalf("render missing %q:\n%s", want, out) } } } func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) { now := time.Now() rows := []swarm.AgentSnapshot{ {ID: "alpha-1", Status: swarm.StatusRunning, Started: now}, } stops := 0 removes := 0 d := newSwarmDialog() d.Open( staticSnapshots(rows...), func(id string) error { stops++; return nil }, func(id string) error { removes++; return nil }, nil, nil, nil, "", ) // Render once so cursor + rows are populated. _ = d.Render(tui.Theme{}, 80) _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'}) if stops != 1 { t.Fatalf("stop calls = %d", stops) } if !strings.Contains(msg, "alpha-1") || errMsg != "" { t.Fatalf("stop status (%q, %q)", msg, errMsg) } _, msg, errMsg = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'r'}) if removes != 1 { t.Fatalf("remove calls = %d", removes) } if !strings.Contains(msg, "alpha-1") || errMsg != "" { t.Fatalf("remove status (%q, %q)", msg, errMsg) } } func TestSwarmDialogEnterShowsTranscript(t *testing.T) { now := time.Now() rows := []swarm.AgentSnapshot{ {ID: "alpha-1", Task: "do stuff", Status: swarm.StatusDone, Tail: "line a\nline b", Started: now}, } d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) out := strings.Join(d.Render(tui.Theme{}, 80), "\n") for _, want := range []string{"task:", "dir:", "line a", "line b"} { if !strings.Contains(out, want) { t.Fatalf("transcript view missing %q:\n%s", want, out) } } // Esc should bring us back to the list view. d.HandleKey(tui.Key{Kind: tui.KeyEsc}) out = strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "STATUS") { t.Fatalf("did not return to list view:\n%s", out) } } func TestSwarmDialogEscClosesList(t *testing.T) { d := newSwarmDialog() d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc}) if !closed { t.Fatal("esc did not close") } if d.Active() { t.Fatal("still active after esc") } } // TestSwarmDialogSpawnFlow drives the inline n-prompt end-to-end: // press n, type a task, press enter, observe the spawn callback // fires with the right text and the prompt closes. func TestSwarmDialogSpawnFlow(t *testing.T) { var spawned string d := newSwarmDialog() d.Open( staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, "", ) _ = d.Render(tui.Theme{}, 80) // Press n to open the spawn editor directly. The model is // taken from whatever the host set via SetCurrentModel; this // test doesn't pin one. d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) if !d.spawning { t.Fatal("n did not enter spawning mode") } out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "enter spawn") || !strings.Contains(out, "@ file/dir picker") { t.Fatalf("spawn editor hint not rendered:\n%s", out) } // Type "fix x". for _, r := range "fix x" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } // Submit. _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if errMsg != "" { t.Fatalf("unexpected err: %s", errMsg) } if msg != "spawned" { t.Fatalf("status msg = %q", msg) } if spawned != "fix x" { t.Fatalf("spawn callback got %q", spawned) } if d.spawning { t.Fatal("prompt did not close after enter") } } func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) { cwd := t.TempDir() if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil { t.Fatal(err) } var spawned string d := newSwarmDialog() d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd) _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) for _, r := range "summarize @READ" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "README.md") { t.Fatalf("@ file picker did not render match:\n%s", out) } // Enter while the file picker is active selects the highlighted file, // inserting the compact [file:] chip instead of submitting yet. d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if d.newTaskEd == nil || !strings.Contains(d.newTaskEd.Value(), "[file:README.md]") { t.Fatalf("file picker did not insert chip; editor=%v", d.newTaskEd) } // A second Enter submits. The chip should expand to the absolute // path before the task reaches Swarm.Spawn. _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if errMsg != "" { t.Fatalf("unexpected err: %s", errMsg) } if msg != "spawned" { t.Fatalf("status msg = %q", msg) } wantPath := filepath.Join(cwd, "README.md") if !strings.Contains(spawned, wantPath) { t.Fatalf("spawned task %q missing expanded path %q", spawned, wantPath) } } func TestSwarmDialogSpawnEscCancels(t *testing.T) { called := false d := newSwarmDialog() d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) for _, r := range "abc" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } d.HandleKey(tui.Key{Kind: tui.KeyEsc}) if called { t.Fatal("spawn callback ran despite esc") } if d.spawning { t.Fatal("prompt still open after esc") } if d.Active() == false { t.Fatal("esc on spawn prompt should not close the whole dialog") } } func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) { d := newSwarmDialog() d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) if d.spawning { t.Fatal("n opened spawn prompt without a spawn callback wired") } } // TestSwarmDialogFullTranscriptRendersEverything pins the /btw-style // rendering: the dialog NEVER clips the transcript into a fixed // window with internal scroll. Every line goes into the rendered // output and the host's outer scrollback owns overflow. This is // what makes the transcript grow with the window and with the // content, instead of cutting off at row 16 with a "↑ N more above" // indicator. func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) { lines := make([]string, 60) for i := range lines { lines[i] = fmt.Sprintf("line %d", i) } rows := []swarm.AgentSnapshot{{ ID: "alpha-1", Task: "x", Status: swarm.StatusDone, Lines: lines, Started: time.Now(), }} d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer out := strings.Join(d.Render(tui.Theme{}, 80), "\n") // First line must be present (no internal clipping from the top). if !strings.Contains(out, "line 0") { t.Errorf("first transcript line missing — the view appears to be clipping:\n%s", out) } // Last line must be present (no internal clipping from the bottom). if !strings.Contains(out, fmt.Sprintf("line %d", len(lines)-1)) { t.Errorf("last transcript line missing — the view appears to be clipping:\n%s", out) } // The old "↑ N more above" / "↓ N more below" cliff markers // must NOT appear; those belonged to the old viewport model. if strings.Contains(out, "more above") || strings.Contains(out, "more below") { t.Errorf("transcript still uses internal scroll markers:\n%s", out) } // Esc returns to the list view. d.HandleKey(tui.Key{Kind: tui.KeyEsc}) if d.viewing { t.Fatal("esc did not exit viewer") } } // TestSwarmDialogOpenViewingJumpsToAgent regression-tests /swarm logs // : OpenViewing should select the row whose id matches the // (possibly truncated) argument and enter the transcript view // directly without showing the list first. func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) { rows := []swarm.AgentSnapshot{ {ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()}, {ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()}, } d := newSwarmDialog() ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "") if !ok { t.Fatal("prefix match did not resolve") } if !d.viewing || d.cursor != 1 { t.Fatalf("viewing=%v cursor=%d", d.viewing, d.cursor) } out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "hi from beta") { t.Fatalf("wrong agent shown:\n%s", out) } } func TestSwarmDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) { d := newSwarmDialog() ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "") if ok { t.Fatal("expected miss for unknown id") } } // TestSwarmDialogPromptFlowFromList drives the inline p-prompt: // press p on the selected agent, type a message, press enter, // and assert the send callback fired with the right id+text. func TestSwarmDialogPromptFlowFromList(t *testing.T) { rows := []swarm.AgentSnapshot{ {ID: "alpha-1", Status: swarm.StatusRunning, Started: time.Now()}, } var gotID, gotText string sends := 0 d := newSwarmDialog() d.Open( staticSnapshots(rows...), nil, nil, nil, func(id, text string) error { sends++; gotID = id; gotText = text; return nil }, nil, "", ) _ = d.Render(tui.Theme{}, 80) // Press 'p' to open the inline prompt for alpha-1. d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) if !d.prompting { t.Fatal("p did not enter prompting mode") } if d.promptTargetID != "alpha-1" { t.Fatalf("prompt target = %q; want alpha-1", d.promptTargetID) } out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "send to alpha-1:") || !strings.Contains(out, "enter send") { t.Fatalf("prompt editor hint not rendered:\n%s", out) } // Type "do the thing" and submit. for _, r := range "do the thing" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if errMsg != "" { t.Fatalf("unexpected err: %s", errMsg) } if msg != "sent to alpha-1" { t.Fatalf("status msg = %q", msg) } if sends != 1 || gotID != "alpha-1" || gotText != "do the thing" { t.Fatalf("send callback got id=%q text=%q (calls=%d)", gotID, gotText, sends) } if d.prompting { t.Fatal("prompt did not close after enter") } } // TestSwarmDialogPromptFromTranscriptView pins the always-on editor // behaviour for the transcript view: entering an agent immediately // auto-opens the inline composer (no `p` needed), typing flows // straight into it, Enter sends, and the metadata stays visible. // The transcript view is /btw-style now — you never leave the // view to send a follow-up. func TestSwarmDialogPromptFromTranscriptView(t *testing.T) { rows := []swarm.AgentSnapshot{ {ID: "beta-2", Task: "do thing", Status: swarm.StatusRunning, Started: time.Now(), Tail: "hi from beta"}, } var gotID, gotText string d := newSwarmDialog() d.Open( staticSnapshots(rows...), nil, nil, nil, func(id, text string) error { gotID = id; gotText = text; return nil }, nil, "", ) // Enter the transcript view; for a running agent the editor // must auto-open right away. _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if !d.viewing { t.Fatal("enter did not open transcript view") } if !d.prompting { t.Fatal("transcript view did not auto-open inline editor for running agent") } out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "task: do thing") { t.Fatalf("transcript metadata not visible:\n%s", out) } if !strings.Contains(out, "swarm: beta-2") { t.Fatalf("frame header missing agent id:\n%s", out) } // Typing flows straight into the editor — no `p` modal step. for _, r := range "keep going" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if msg != "sent to beta-2" || gotID != "beta-2" || gotText != "keep going" { t.Fatalf("send didn't fire correctly: msg=%q id=%q text=%q", msg, gotID, gotText) } // Editor must stay mounted after submit (/btw-style), with an // empty buffer ready for the next message. if !d.prompting { t.Error("editor closed after submit; /btw-style transcript view should keep it mounted") } if !d.viewing { t.Error("transcript view collapsed after submit; should stay open") } if d.promptEd != nil && d.promptEd.Value() != "" { t.Errorf("editor buffer not cleared after submit: %q", d.promptEd.Value()) } } // TestSwarmDialogPromptEscCancels makes sure Esc inside the prompt // editor aborts without invoking the send callback. func TestSwarmDialogPromptEscCancels(t *testing.T) { rows := []swarm.AgentSnapshot{{ID: "gamma-3", Status: swarm.StatusRunning, Started: time.Now()}} sends := 0 d := newSwarmDialog() d.Open( staticSnapshots(rows...), nil, nil, nil, func(id, text string) error { sends++; return nil }, nil, "", ) _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) for _, r := range "oops" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } d.HandleKey(tui.Key{Kind: tui.KeyEsc}) if d.prompting { t.Fatal("esc did not close prompt editor") } if sends != 0 { t.Fatalf("send fired despite esc: calls=%d", sends) } } // TestSwarmDialogPWithoutSendCallback verifies that pressing 'p' // when no send callback is wired is a silent no-op (mirrors the // existing 'n' / no-spawn-callback contract). func TestSwarmDialogPWithoutSendCallback(t *testing.T) { rows := []swarm.AgentSnapshot{{ID: "delta-4", Status: swarm.StatusRunning, Started: time.Now()}} d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) if d.prompting { t.Fatal("p opened prompt editor without a send callback") } } // TestOpenForResumeParksCursorOnFirstResumable verifies the picker // entry point: with a mix of running and detached agents, the // dialog opens with the cursor on the first detached row (skipping // any leading running ones) so the user can press R immediately. func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) { rows := []swarm.AgentSnapshot{ {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, {ID: "live-2", Status: swarm.StatusRunning, Started: time.Now()}, {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()}, {ID: "detached-2", Status: swarm.StatusKilled, Started: time.Now()}, } d := newSwarmDialog() count := d.OpenForResume( staticSnapshots(rows...), nil, nil, nil, nil, func(id string) error { return nil }, "", ) if count != 2 { t.Fatalf("resumable count = %d; want 2", count) } if d.cursor != 2 { t.Fatalf("cursor = %d; want 2 (first resumable row)", d.cursor) } if sel := d.selected(); sel == nil || sel.ID != "detached-1" { t.Fatalf("selected = %+v; want detached-1", sel) } } // TestOpenForResumeWithNoResumableLeavesDialogActive ensures the // picker still opens the dashboard when nothing is resumable, so the // user sees what's there instead of getting silently nothing. The // slash handler is responsible for surfacing the "no resumable // agents" status string — the dialog itself just opens. func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) { rows := []swarm.AgentSnapshot{ {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, } d := newSwarmDialog() count := d.OpenForResume( staticSnapshots(rows...), nil, nil, nil, nil, func(id string) error { return nil }, "", ) if count != 0 { t.Fatalf("resumable count = %d; want 0", count) } if !d.Active() { t.Fatal("dialog should remain active so the user sees the empty state") } if d.cursor != 0 { t.Fatalf("cursor = %d; want 0 (no parking)", d.cursor) } } // TestOpenForResumeREnterTriggersResume drives the full picker UX: // /swarm resume → dialog opens parked on a resumable row → user // presses R → the resume callback fires for that exact id. func TestOpenForResumeREnterTriggersResume(t *testing.T) { rows := []swarm.AgentSnapshot{ {ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()}, {ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()}, } var resumed string d := newSwarmDialog() d.OpenForResume( staticSnapshots(rows...), nil, nil, nil, nil, func(id string) error { resumed = id; return nil }, "", ) _ = d.Render(tui.Theme{}, 80) _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'R'}) if errMsg != "" { t.Fatalf("unexpected err: %s", errMsg) } if resumed != "detached-1" { t.Fatalf("resume callback got %q; want detached-1", resumed) } if msg != "resumed detached-1" { t.Fatalf("status msg = %q", msg) } } // TestNeedsTickRefresh enumerates the four dialog states so the host's // animation tick wakes the renderer for live updates but stays out of // the way of the inline editors (which need the cursor blink). func TestNeedsTickRefresh(t *testing.T) { // Closed. d := newSwarmDialog() if d.NeedsTickRefresh() { t.Fatal("closed dialog should not request tick refresh") } // List view (open, no editors): tick should refresh so rows update. d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") if !d.NeedsTickRefresh() { t.Fatal("open list view should request tick refresh") } // Transcript view: still background-driven; tick should refresh. rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if !d.viewing { t.Fatal("setup: enter did not open transcript view") } if !d.NeedsTickRefresh() { t.Fatal("transcript view should request tick refresh") } // Spawn editor open: tick should NOT refresh (cursor blink). d = newSwarmDialog() d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) if !d.spawning { t.Fatal("setup: n did not enter spawning mode") } if d.NeedsTickRefresh() { t.Fatal("spawn editor should suppress tick refresh") } // Prompt editor open: same suppression. d = newSwarmDialog() rows = []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) if !d.prompting { t.Fatal("setup: p did not enter prompting mode") } if d.NeedsTickRefresh() { t.Fatal("prompt editor should suppress tick refresh") } } // TestCursorPosPromptEditor verifies the dialog reports a real // caret row for the prompt editor in both list-view and // transcript-view contexts. Without this, the terminal cursor // stayed at the (now-hidden) main editor row when the user // pressed 'p', so the visible caret looked wrong. func TestCursorPosPromptEditor(t *testing.T) { rows := []swarm.AgentSnapshot{ {ID: "a", Task: "t", Status: swarm.StatusRunning, Started: time.Now()}, } // From the list view. d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) if !d.prompting { t.Fatal("setup: p did not enter prompting from list view") } row, _ := d.CursorPos(80) if row < 1 { t.Fatalf("list-view prompt CursorPos row = %d; want >= 1", row) } // From the transcript view (deeper offset due to metadata block). d = newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) if !d.prompting || !d.viewing { t.Fatalf("setup: prompting=%v viewing=%v", d.prompting, d.viewing) } rowT, _ := d.CursorPos(80) if rowT <= row { t.Fatalf("transcript-view prompt row = %d; should be deeper than list row %d", rowT, row) } } func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) { d := newSwarmDialog() d.SetCurrentModel("claude-sonnet-4-5", "anthropic") d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) if !d.spawning { t.Fatal("setup: n did not enter spawning mode") } row, _ := d.CursorPos(80) lines := padDialogFrame(d.Render(tui.Theme{}, 80)) if row < 0 || row >= len(lines) { t.Fatalf("spawn CursorPos row = %d outside rendered lines %d", row, len(lines)) } if got := stripANSIBytes(lines[row]); !strings.Contains(got, "▌") { t.Fatalf("spawn CursorPos row %d = %q; want editor prompt row", row, got) } } // TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression // caught by the user's screenshot: pressing 'p' on a detached agent // dialled a dead unix socket and printed the raw path as an error. // We now block the prompt editor for any non-running status and // surface a clear status-bar hint instead. func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) { for _, st := range []swarm.Status{ swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, } { t.Run(string(st), func(t *testing.T) { rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} sends := 0 d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { sends++; return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) _, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) if d.prompting { t.Fatal("prompt editor opened on non-running agent") } if sends != 0 { t.Fatal("send callback fired without an open editor") } if msg != "" { t.Errorf("unexpected ok msg: %q", msg) } if !strings.Contains(errMsg, string(st)) { t.Errorf("hint = %q; should mention status %q", errMsg, st) } if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning { t.Errorf("hint = %q; should mention resume for resumable state", errMsg) } }) } } // TestSwarmDialogPOnRunningAgentOpensEditor is the positive // complement of the previous test: running agents still open the // editor normally. func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) { rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}} d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) if !d.prompting { t.Fatal("prompt editor did not open on running agent") } } // TestFriendlySendErrRewritesNotReady asserts ErrNotReady becomes the // user-friendly "isn't accepting input" hint, and unknown errors // fall through verbatim. func TestFriendlySendErrRewritesNotReady(t *testing.T) { hint := friendlySendErr("alpha-1", swarm.ErrNotReady) if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") { t.Errorf("friendlySendErr(ErrNotReady) = %q", hint) } if strings.Contains(hint, "swarm:") { t.Errorf("friendlySendErr leaked raw error text: %q", hint) } other := errors.New("some other failure") got := friendlySendErr("alpha-1", other) if got != "send: some other failure" { t.Errorf("friendlySendErr passthrough = %q", got) } } // TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat // transcript log is regrouped into user / assistant / stderr / // error blocks before rendering. Each output row should be styled // for its role rather than printed verbatim. func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) { lines := []string{ "user: hello", "hi there", "second assistant line", "stderr: warning thing", "error: boom", } out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) joined := strings.Join(out, "\n") // The user echo should appear as a bubble row, which contains // the "▌ " left-bar glyph. if !strings.Contains(joined, "▌ ") { t.Errorf("user echo did not render as a bubble row:\n%s", joined) } // The user prefix should be stripped from the rendered output. if strings.Contains(joined, "user: hello") { t.Errorf("user prefix leaked into rendered output") } // Assistant content survives. if !strings.Contains(joined, "hi there") { t.Errorf("assistant text missing from rendered output") } // Stderr line is prefixed with stderr marker (we keep "stderr" // word in the rendered output for clarity). if !strings.Contains(joined, "stderr") || !strings.Contains(joined, "warning thing") { t.Errorf("stderr block missing:\n%s", joined) } // Error line gets the ✖ marker. if !strings.Contains(joined, "✖ boom") { t.Errorf("error block missing ✖ marker:\n%s", joined) } } // TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the // assistant text goes through tui.RenderMarkdown: a literal **bold** // pair should become an ANSI bold sequence and bullet "-" should // become "•". func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) { lines := []string{ "Here is a list:", "- **bold** item", "- plain item", } out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) joined := strings.Join(out, "\n") // ANSI bold on/off. Markdown rendering uses CSI 1 / 22. if !strings.Contains(joined, "\x1b[1m") || !strings.Contains(joined, "\x1b[22m") { t.Errorf("expected ANSI bold sequences:\n%q", joined) } // Bullet glyph upgrade. if !strings.Contains(joined, "• ") { t.Errorf("expected • bullet:\n%s", joined) } // Literal "**bold**" markdown source must NOT leak through. if strings.Contains(joined, "**bold**") { t.Errorf("unrendered markdown leaked through:\n%s", joined) } } // TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines // guards the contract that consecutive assistant lines form one // markdown block. If we render each line in isolation, fenced code // (and other multi-line constructs) would never close. func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) { lines := []string{ "Some prose.", "```go", "package main", "", "func main() {}", "```", "Done.", } out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80) joined := strings.Join(out, "\n") // The literal fence markers should be consumed by the markdown // renderer; if we rendered each line on its own the ``` would // leak as text. if strings.Contains(joined, "```") { t.Errorf("fence markers leaked — lines weren't grouped:\n%s", joined) } // The code content should still be in the output — strip ANSI // before checking because syntax highlighting wraps each token. plain := stripANSIBytes(joined) if !strings.Contains(plain, "package main") || !strings.Contains(plain, "func main") { t.Errorf("code block content missing:\nansi=%q\nplain=%q", joined, plain) } } // TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt- // disabled test: pressing 'k' on an already-terminal or detached // agent now surfaces a hint instead of routing to Swarm.Stop, which // is a no-op for those states (and used to segfault for detached // agents before the cancel-nil guard). func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) { for _, st := range []swarm.Status{ swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, } { t.Run(string(st), func(t *testing.T) { rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}} stops := 0 d := newSwarmDialog() d.Open(staticSnapshots(rows...), func(string) error { stops++; return nil }, nil, nil, nil, nil, "") _ = d.Render(tui.Theme{}, 80) _, _, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'}) if stops != 0 { t.Fatalf("stop callback fired on %s agent", st) } if !strings.Contains(errMsg, string(st)) || !strings.Contains(errMsg, "remove") { t.Errorf("hint = %q; should mention %q and suggest remove", errMsg, st) } }) } } // TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn // callback receives whatever model the host pinned via // SetCurrentModel before opening the dialog. This is how /model is // surfaced to swarm agents: the user picks via /model first, then // /swarm n, and the new agent inherits. func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) { var gotTask, gotModel, gotProv string d := newSwarmDialog() d.SetCurrentModel("claude-sonnet-4-5", "anthropic") d.Open(staticSnapshots(), nil, nil, func(task, model, prov string) error { gotTask, gotModel, gotProv = task, model, prov return nil }, nil, nil, "") _ = d.Render(tui.Theme{}, 80) // `n` goes straight to the spawn editor; no picker step. d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) if !d.spawning { t.Fatal("n did not enter spawning mode") } // The spawn editor advertises the inherited model so the user // knows what they're about to spawn against. out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "model: claude-sonnet-4-5") { t.Errorf("spawn editor missing model banner:\n%s", out) } for _, r := range "do thing" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if gotTask != "do thing" || gotModel != "claude-sonnet-4-5" || gotProv != "anthropic" { t.Fatalf("spawn args = (%q,%q,%q); want (\"do thing\",\"claude-sonnet-4-5\",\"anthropic\")", gotTask, gotModel, gotProv) } if !strings.Contains(msg, "claude-sonnet-4-5") { t.Errorf("status msg = %q; should mention inherited model", msg) } } // TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in- // editor /model command pops the same model dialog the global // /model uses, that picking a model captures it as the pending // override, and that the spawn editor reopens with any previously- // typed task text intact so the user doesn't lose work. func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) { var gotTask, gotModel, gotProv string d := newSwarmDialog() d.SetCurrentModel("old-model", "old-prov") d.Open(staticSnapshots(), nil, nil, func(task, model, prov string) error { gotTask, gotModel, gotProv = task, model, prov return nil }, nil, nil, "") _ = d.Render(tui.Theme{}, 80) // Open spawn editor and type some task text plus a /model line. d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) if !d.spawning { t.Fatal("n did not enter spawning mode") } for _, r := range "refactor X" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } // Newline so the editor has a two-line buffer when we add /model. d.newTaskEd.SetValue("refactor X\n/model") d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if !d.pickingModel { t.Fatalf("/model submit did not open the picker (spawning=%v picking=%v)", d.spawning, d.pickingModel) } if d.spawning { t.Fatal("spawn editor still marked open while picker is up") } if d.spawnDraft != "refactor X" { t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X") } // Picker render must take over the dialog frame. out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "select model for next spawn") { t.Errorf("picker render hint missing:\n%s", out) } // Inject a fake selection into the embedded modelDialog and confirm. d.modelPicker.all = []provider.Model{{Provider: "acme", ID: "fake-1"}} d.modelPicker.view = d.modelPicker.all d.modelPicker.cursor = 0 _, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if d.pickingModel { t.Fatal("picker did not close on select") } if !d.spawning { t.Fatal("spawn editor did not reopen after picker select") } if d.pendingModel != "fake-1" || d.pendingProvider != "acme" { t.Errorf("pending = (%q,%q); want (fake-1, acme)", d.pendingModel, d.pendingProvider) } if !strings.Contains(msg, "fake-1") { t.Errorf("status msg = %q; should mention picked model", msg) } if got := d.newTaskEd.Value(); got != "refactor X" { t.Errorf("reopened editor buffer = %q; want preserved task %q", got, "refactor X") } // Now submit the task and confirm the spawn callback gets the // new model. d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if gotTask != "refactor X" || gotModel != "fake-1" || gotProv != "acme" { t.Errorf("spawn args = (%q,%q,%q); want (refactor X, fake-1, acme)", gotTask, gotModel, gotProv) } } // TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms // pressing Esc inside the picker (no selection) reopens the spawn // editor with the previously-typed text intact AND leaves the // pending model untouched, so the user can bail out cleanly. func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) { d := newSwarmDialog() d.SetCurrentModel("keep-me", "keep-prov") d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) d.newTaskEd.SetValue("task here\n/model") d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if !d.pickingModel { t.Fatal("/model did not open the picker") } d.HandleKey(tui.Key{Kind: tui.KeyEsc}) if d.pickingModel { t.Fatal("esc did not close the picker") } if !d.spawning { t.Fatal("spawn editor did not reopen after picker cancel") } if d.pendingModel != "keep-me" || d.pendingProvider != "keep-prov" { t.Errorf("pending changed on Esc: (%q,%q); want (keep-me, keep-prov)", d.pendingModel, d.pendingProvider) } if got := d.newTaskEd.Value(); got != "task here" { t.Errorf("reopened editor buffer = %q; want %q", got, "task here") } } // TestStripSlashModelLine pins the helper's behaviour: drop any // line whose trimmed content is "/model" or starts with "/model ", // preserve everything else verbatim. func TestStripSlashModelLine(t *testing.T) { cases := []struct{ in, want string }{ {"/model", ""}, {" /model ", ""}, {"foo\n/model", "foo"}, {"/model\nfoo", "foo"}, {"foo\n/model\nbar", "foo\nbar"}, {"/model gpt-5", ""}, {"a /model in prose", "a /model in prose"}, // not first non-blank token → keep {"", ""}, {"plain task", "plain task"}, } for _, c := range cases { if got := stripSlashModelLine(c.in); got != c.want { t.Errorf("stripSlashModelLine(%q) = %q; want %q", c.in, got, c.want) } } } // TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that // when the host doesn't pin a model (SetCurrentModel never called or // called with empty strings), the spawn callback receives empty // model/provider and the child subprocess resolves its own default. func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) { var gotModel, gotProv string d := newSwarmDialog() d.Open(staticSnapshots(), nil, nil, func(_, model, prov string) error { gotModel, gotProv = model, prov return nil }, nil, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'}) for _, r := range "do thing" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if gotModel != "" || gotProv != "" { t.Fatalf("spawn args = (model=%q, prov=%q); want both empty", gotModel, gotProv) } } // TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style // "the transcript grows with the content" property: when the agent // reply lands in the snapshot, the next render includes it in full, // alongside everything that was already there. Replaces the old // followTail/scroll tests that pinned a viewport mechanic we no // longer have. func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) { cur := []swarm.AgentSnapshot{{ ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Lines: []string{"old line 1", "old line 2"}, Started: time.Now(), }} snap := func() []swarm.AgentSnapshot { return cur } d := newSwarmDialog() d.Open(snap, nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "old line 1") || !strings.Contains(out, "old line 2") { t.Fatalf("initial render missing existing lines:\n%s", out) } // Agent appends a fresh reply: the very next render must show // both the new content AND keep the old content (no clipping). cur = []swarm.AgentSnapshot{{ ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Lines: []string{"old line 1", "old line 2", "new reply A", "new reply B"}, Started: cur[0].Started, }} out = strings.Join(d.Render(tui.Theme{}, 80), "\n") for _, want := range []string{"old line 1", "old line 2", "new reply A", "new reply B"} { if !strings.Contains(out, want) { t.Errorf("line %q missing after growth:\n%s", want, out) } } } // TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow // for the transcript view: after one Enter-submit the editor stays // mounted and clears, and a second message can be composed and sent // without any modal toggle. Regresses the previous design where // every send closed the prompt and forced the user to press `p` // again. func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) { rows := []swarm.AgentSnapshot{{ ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(), Lines: []string{"warm-up"}, }} var sent []string d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(_, text string) error { sent = append(sent, text); return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript + auto-editor // First message. for _, r := range "first" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // Second message, no `p` toggle in between. for _, r := range "second" { d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r}) } d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if len(sent) != 2 || sent[0] != "first" || sent[1] != "second" { t.Fatalf("sent = %v; want [first second]", sent) } if !d.viewing || !d.prompting { t.Errorf("transcript view/editor torn down between sends (viewing=%v prompting=%v)", d.viewing, d.prompting) } } // TestSwarmTranscriptEscClosesView pins the Esc semantics for the // /btw-style transcript view: a single Esc closes the whole view // (back to the dashboard list), regardless of whether the editor // is currently composing. Without this routing the editor's own // Esc handler would just clear the modal and leave the user stuck // inside an empty transcript view. func TestSwarmTranscriptEscClosesView(t *testing.T) { rows := []swarm.AgentSnapshot{{ ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(), Lines: []string{"hi"}, }} d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if !d.viewing || !d.prompting { t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting) } d.HandleKey(tui.Key{Kind: tui.KeyEsc}) if d.viewing { t.Error("Esc did not close the transcript view") } if d.prompting { t.Error("Esc did not tear down the inline editor") } } // TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net: // transcripts for done / failed / killed agents must NOT auto-open // an editor (the agent's inbox isn't listening, so a send would // just bounce). The view shows the transcript plus a hint about // resuming instead. func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) { rows := []swarm.AgentSnapshot{{ ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(), Lines: []string{"all done"}, }} d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if d.prompting { t.Fatal("editor auto-opened for a done agent; should only happen for running ones") } out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "prompt: agent is done") { t.Errorf("hint about non-running status missing:\n%s", out) } } // TestSwarmTranscriptTickRefreshWhileEditing pins the streaming // fix: with the always-on editor in transcript view, the host's // animation tick MUST still call us so streaming agent output // renders. Without this, sending a message and watching the reply // land silently failed because the prompting=true branch was // suppressing redraws (like the modal editors do). func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) { rows := []swarm.AgentSnapshot{{ ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Activity: "thinking", Started: time.Now(), Lines: []string{"hello"}, }} d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) if !d.viewing || !d.prompting { t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting) } if !d.NeedsTickRefresh() { t.Fatal("transcript view with always-on editor refused tick refresh; streaming output would freeze") } } // TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse: // the list-view `p` modal (prompting without viewing) still // suppresses tick like the other modal editors, so the cursor // blink works cleanly while composing a one-off prompt. func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) { rows := []swarm.AgentSnapshot{{ ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(), }} d := newSwarmDialog() d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'}) if d.viewing || !d.prompting { t.Fatalf("setup: viewing=%v prompting=%v; want list-view modal", d.viewing, d.prompting) } if d.NeedsTickRefresh() { t.Error("list-view prompt modal did not suppress tick refresh; cursor blink may misbehave") } } // TestSwarmTranscriptBusySpinnerRenders proves the inline spinner // appears above the always-on editor while the agent is busy and // disappears when it goes idle. This is the /btw-style "something // is happening" feedback the user was missing. func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) { cur := []swarm.AgentSnapshot{{ ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Activity: "thinking", Started: time.Now(), Lines: []string{"hello"}, }} snap := func() []swarm.AgentSnapshot { return cur } d := newSwarmDialog() d.Open(snap, nil, nil, nil, func(string, string) error { return nil }, nil, "") _ = d.Render(tui.Theme{}, 80) d.HandleKey(tui.Key{Kind: tui.KeyEnter}) out := strings.Join(d.Render(tui.Theme{}, 80), "\n") if !strings.Contains(out, "thinking") { t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out) } if d.transcriptSpin == nil { t.Error("spinner not initialised while agent is busy") } // Flip the activity to idle: spinner should vanish. cur[0].Activity = "idle" out = strings.Join(d.Render(tui.Theme{}, 80), "\n") if d.transcriptSpin != nil { t.Error("spinner stayed live after agent reported idle") } // The bare word "thinking" must not appear anymore. if strings.Contains(out, "· thinking, ") { t.Errorf("busy line still rendered when agent is idle:\n%s", out) } } func TestStatusLabelCoversAll(t *testing.T) { for _, s := range []swarm.Status{ swarm.StatusPending, swarm.StatusRunning, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled, } { if statusLabel(s) == "" { t.Fatalf("no label for %s", s) } } }