v0.1.91 91c696d

swarm_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"errors"
5	"fmt"
6	"os"
7	"path/filepath"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/patriceckhart/zot/internal/provider"
13	"github.com/patriceckhart/zot/internal/swarm"
14	"github.com/patriceckhart/zot/internal/tui"
15)
16
17// staticSnapshots returns a deterministic snapshot slice for the
18// dialog to render.
19func staticSnapshots(rows ...swarm.AgentSnapshot) func() []swarm.AgentSnapshot {
20	return func() []swarm.AgentSnapshot {
21		out := make([]swarm.AgentSnapshot, len(rows))
22		copy(out, rows)
23		return out
24	}
25}
26
27func TestSwarmDialogEmptyState(t *testing.T) {
28	d := newSwarmDialog()
29	d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
30	lines := d.Render(tui.Theme{}, 80)
31	joined := strings.Join(lines, "\n")
32	if !strings.Contains(joined, "no agents") {
33		t.Fatalf("empty state missing hint:\n%s", joined)
34	}
35	if !strings.Contains(joined, "press n") {
36		t.Fatalf("empty state should advertise n shortcut:\n%s", joined)
37	}
38}
39
40func TestSwarmDialogRendersRows(t *testing.T) {
41	now := time.Now()
42	rows := []swarm.AgentSnapshot{
43		{ID: "alpha-1", Task: "fix login", Status: swarm.StatusRunning, Activity: "editing file", Started: now},
44		{ID: "beta-2", Task: "write tests", Status: swarm.StatusDone, Activity: "done", Started: now},
45	}
46	d := newSwarmDialog()
47	d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
48	out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
49	for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} {
50		if !strings.Contains(out, want) {
51			t.Fatalf("render missing %q:\n%s", want, out)
52		}
53	}
54}
55
56func TestSwarmDialogStopAndRemoveCallbacks(t *testing.T) {
57	now := time.Now()
58	rows := []swarm.AgentSnapshot{
59		{ID: "alpha-1", Status: swarm.StatusRunning, Started: now},
60	}
61	stops := 0
62	removes := 0
63	d := newSwarmDialog()
64	d.Open(
65		staticSnapshots(rows...),
66		func(id string) error { stops++; return nil },
67		func(id string) error { removes++; return nil },
68		nil,
69		nil,
70		nil,
71		"",
72	)
73	// Render once so cursor + rows are populated.
74	_ = d.Render(tui.Theme{}, 80)
75
76	_, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'})
77	if stops != 1 {
78		t.Fatalf("stop calls = %d", stops)
79	}
80	if !strings.Contains(msg, "alpha-1") || errMsg != "" {
81		t.Fatalf("stop status (%q, %q)", msg, errMsg)
82	}
83
84	_, msg, errMsg = d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'r'})
85	if removes != 1 {
86		t.Fatalf("remove calls = %d", removes)
87	}
88	if !strings.Contains(msg, "alpha-1") || errMsg != "" {
89		t.Fatalf("remove status (%q, %q)", msg, errMsg)
90	}
91}
92
93func TestSwarmDialogEnterShowsTranscript(t *testing.T) {
94	now := time.Now()
95	rows := []swarm.AgentSnapshot{
96		{ID: "alpha-1", Task: "do stuff", Status: swarm.StatusDone, Tail: "line a\nline b", Started: now},
97	}
98	d := newSwarmDialog()
99	d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
100	_ = d.Render(tui.Theme{}, 80)
101	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
102	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
103	for _, want := range []string{"task:", "dir:", "line a", "line b"} {
104		if !strings.Contains(out, want) {
105			t.Fatalf("transcript view missing %q:\n%s", want, out)
106		}
107	}
108	// Esc should bring us back to the list view.
109	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
110	out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
111	if !strings.Contains(out, "STATUS") {
112		t.Fatalf("did not return to list view:\n%s", out)
113	}
114}
115
116func TestSwarmDialogEscClosesList(t *testing.T) {
117	d := newSwarmDialog()
118	d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
119	closed, _, _ := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
120	if !closed {
121		t.Fatal("esc did not close")
122	}
123	if d.Active() {
124		t.Fatal("still active after esc")
125	}
126}
127
128// TestSwarmDialogSpawnFlow drives the inline n-prompt end-to-end:
129// press n, type a task, press enter, observe the spawn callback
130// fires with the right text and the prompt closes.
131func TestSwarmDialogSpawnFlow(t *testing.T) {
132	var spawned string
133	d := newSwarmDialog()
134	d.Open(
135		staticSnapshots(),
136		nil, nil,
137		func(task, _, _ string) error { spawned = task; return nil },
138		nil,
139		nil,
140		"",
141	)
142	_ = d.Render(tui.Theme{}, 80)
143
144	// Press n to open the spawn editor directly. The model is
145	// taken from whatever the host set via SetCurrentModel; this
146	// test doesn't pin one.
147	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
148	if !d.spawning {
149		t.Fatal("n did not enter spawning mode")
150	}
151	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
152	if !strings.Contains(out, "enter spawn") || !strings.Contains(out, "@ file/dir picker") {
153		t.Fatalf("spawn editor hint not rendered:\n%s", out)
154	}
155
156	// Type "fix x".
157	for _, r := range "fix x" {
158		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
159	}
160	// Submit.
161	_, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
162	if errMsg != "" {
163		t.Fatalf("unexpected err: %s", errMsg)
164	}
165	if msg != "spawned" {
166		t.Fatalf("status msg = %q", msg)
167	}
168	if spawned != "fix x" {
169		t.Fatalf("spawn callback got %q", spawned)
170	}
171	if d.spawning {
172		t.Fatal("prompt did not close after enter")
173	}
174}
175
176func TestSwarmDialogSpawnFilePickerFlow(t *testing.T) {
177	cwd := t.TempDir()
178	if err := os.WriteFile(filepath.Join(cwd, "README.md"), []byte("hi"), 0o644); err != nil {
179		t.Fatal(err)
180	}
181	var spawned string
182	d := newSwarmDialog()
183	d.Open(staticSnapshots(), nil, nil, func(task, _, _ string) error { spawned = task; return nil }, nil, nil, cwd)
184	_ = d.Render(tui.Theme{}, 80)
185
186	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
187	for _, r := range "summarize @READ" {
188		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
189	}
190	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
191	if !strings.Contains(out, "README.md") {
192		t.Fatalf("@ file picker did not render match:\n%s", out)
193	}
194
195	// Enter while the file picker is active selects the highlighted file,
196	// inserting the compact [file:] chip instead of submitting yet.
197	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
198	if d.newTaskEd == nil || !strings.Contains(d.newTaskEd.Value(), "[file:README.md]") {
199		t.Fatalf("file picker did not insert chip; editor=%v", d.newTaskEd)
200	}
201
202	// A second Enter submits. The chip should expand to the absolute
203	// path before the task reaches Swarm.Spawn.
204	_, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
205	if errMsg != "" {
206		t.Fatalf("unexpected err: %s", errMsg)
207	}
208	if msg != "spawned" {
209		t.Fatalf("status msg = %q", msg)
210	}
211	wantPath := filepath.Join(cwd, "README.md")
212	if !strings.Contains(spawned, wantPath) {
213		t.Fatalf("spawned task %q missing expanded path %q", spawned, wantPath)
214	}
215}
216
217func TestSwarmDialogSpawnEscCancels(t *testing.T) {
218	called := false
219	d := newSwarmDialog()
220	d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { called = true; return nil }, nil, nil, "")
221	_ = d.Render(tui.Theme{}, 80)
222
223	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
224	for _, r := range "abc" {
225		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
226	}
227	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
228	if called {
229		t.Fatal("spawn callback ran despite esc")
230	}
231	if d.spawning {
232		t.Fatal("prompt still open after esc")
233	}
234	if d.Active() == false {
235		t.Fatal("esc on spawn prompt should not close the whole dialog")
236	}
237}
238
239func TestSwarmDialogNoNWithoutSpawnCallback(t *testing.T) {
240	d := newSwarmDialog()
241	d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "") // spawn callback nil
242	_ = d.Render(tui.Theme{}, 80)
243	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
244	if d.spawning {
245		t.Fatal("n opened spawn prompt without a spawn callback wired")
246	}
247}
248
249// TestSwarmDialogFullTranscriptRendersEverything pins the /btw-style
250// rendering: the dialog NEVER clips the transcript into a fixed
251// window with internal scroll. Every line goes into the rendered
252// output and the host's outer scrollback owns overflow. This is
253// what makes the transcript grow with the window and with the
254// content, instead of cutting off at row 16 with a "↑ N more above"
255// indicator.
256func TestSwarmDialogFullTranscriptRendersEverything(t *testing.T) {
257	lines := make([]string, 60)
258	for i := range lines {
259		lines[i] = fmt.Sprintf("line %d", i)
260	}
261	rows := []swarm.AgentSnapshot{{
262		ID: "alpha-1", Task: "x", Status: swarm.StatusDone,
263		Lines: lines, Started: time.Now(),
264	}}
265	d := newSwarmDialog()
266	d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
267	_ = d.Render(tui.Theme{}, 80)
268	d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open viewer
269
270	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
271	// First line must be present (no internal clipping from the top).
272	if !strings.Contains(out, "line 0") {
273		t.Errorf("first transcript line missing — the view appears to be clipping:\n%s", out)
274	}
275	// Last line must be present (no internal clipping from the bottom).
276	if !strings.Contains(out, fmt.Sprintf("line %d", len(lines)-1)) {
277		t.Errorf("last transcript line missing — the view appears to be clipping:\n%s", out)
278	}
279	// The old "↑ N more above" / "↓ N more below" cliff markers
280	// must NOT appear; those belonged to the old viewport model.
281	if strings.Contains(out, "more above") || strings.Contains(out, "more below") {
282		t.Errorf("transcript still uses internal scroll markers:\n%s", out)
283	}
284
285	// Esc returns to the list view.
286	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
287	if d.viewing {
288		t.Fatal("esc did not exit viewer")
289	}
290}
291
292// TestSwarmDialogOpenViewingJumpsToAgent regression-tests /swarm logs
293// <id>: OpenViewing should select the row whose id matches the
294// (possibly truncated) argument and enter the transcript view
295// directly without showing the list first.
296func TestSwarmDialogOpenViewingJumpsToAgent(t *testing.T) {
297	rows := []swarm.AgentSnapshot{
298		{ID: "alpha-1", Lines: []string{"hi from alpha"}, Started: time.Now()},
299		{ID: "beta-2", Lines: []string{"hi from beta"}, Started: time.Now()},
300	}
301	d := newSwarmDialog()
302	ok := d.OpenViewing("beta", staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
303	if !ok {
304		t.Fatal("prefix match did not resolve")
305	}
306	if !d.viewing || d.cursor != 1 {
307		t.Fatalf("viewing=%v cursor=%d", d.viewing, d.cursor)
308	}
309	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
310	if !strings.Contains(out, "hi from beta") {
311		t.Fatalf("wrong agent shown:\n%s", out)
312	}
313}
314
315func TestSwarmDialogOpenViewingUnknownIDReturnsFalse(t *testing.T) {
316	d := newSwarmDialog()
317	ok := d.OpenViewing("nope", staticSnapshots(), nil, nil, nil, nil, nil, "")
318	if ok {
319		t.Fatal("expected miss for unknown id")
320	}
321}
322
323// TestSwarmDialogPromptFlowFromList drives the inline p-prompt:
324// press p on the selected agent, type a message, press enter,
325// and assert the send callback fired with the right id+text.
326func TestSwarmDialogPromptFlowFromList(t *testing.T) {
327	rows := []swarm.AgentSnapshot{
328		{ID: "alpha-1", Status: swarm.StatusRunning, Started: time.Now()},
329	}
330	var gotID, gotText string
331	sends := 0
332	d := newSwarmDialog()
333	d.Open(
334		staticSnapshots(rows...),
335		nil, nil, nil,
336		func(id, text string) error { sends++; gotID = id; gotText = text; return nil },
337		nil,
338		"",
339	)
340	_ = d.Render(tui.Theme{}, 80)
341
342	// Press 'p' to open the inline prompt for alpha-1.
343	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
344	if !d.prompting {
345		t.Fatal("p did not enter prompting mode")
346	}
347	if d.promptTargetID != "alpha-1" {
348		t.Fatalf("prompt target = %q; want alpha-1", d.promptTargetID)
349	}
350	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
351	if !strings.Contains(out, "send to alpha-1:") || !strings.Contains(out, "enter send") {
352		t.Fatalf("prompt editor hint not rendered:\n%s", out)
353	}
354
355	// Type "do the thing" and submit.
356	for _, r := range "do the thing" {
357		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
358	}
359	_, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
360	if errMsg != "" {
361		t.Fatalf("unexpected err: %s", errMsg)
362	}
363	if msg != "sent to alpha-1" {
364		t.Fatalf("status msg = %q", msg)
365	}
366	if sends != 1 || gotID != "alpha-1" || gotText != "do the thing" {
367		t.Fatalf("send callback got id=%q text=%q (calls=%d)", gotID, gotText, sends)
368	}
369	if d.prompting {
370		t.Fatal("prompt did not close after enter")
371	}
372}
373
374// TestSwarmDialogPromptFromTranscriptView pins the always-on editor
375// behaviour for the transcript view: entering an agent immediately
376// auto-opens the inline composer (no `p` needed), typing flows
377// straight into it, Enter sends, and the metadata stays visible.
378// The transcript view is /btw-style now — you never leave the
379// view to send a follow-up.
380func TestSwarmDialogPromptFromTranscriptView(t *testing.T) {
381	rows := []swarm.AgentSnapshot{
382		{ID: "beta-2", Task: "do thing", Status: swarm.StatusRunning, Started: time.Now(), Tail: "hi from beta"},
383	}
384	var gotID, gotText string
385	d := newSwarmDialog()
386	d.Open(
387		staticSnapshots(rows...),
388		nil, nil, nil,
389		func(id, text string) error { gotID = id; gotText = text; return nil },
390		nil,
391		"",
392	)
393	// Enter the transcript view; for a running agent the editor
394	// must auto-open right away.
395	_ = d.Render(tui.Theme{}, 80)
396	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
397	if !d.viewing {
398		t.Fatal("enter did not open transcript view")
399	}
400	if !d.prompting {
401		t.Fatal("transcript view did not auto-open inline editor for running agent")
402	}
403	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
404	if !strings.Contains(out, "task:   do thing") {
405		t.Fatalf("transcript metadata not visible:\n%s", out)
406	}
407	if !strings.Contains(out, "swarm: beta-2") {
408		t.Fatalf("frame header missing agent id:\n%s", out)
409	}
410
411	// Typing flows straight into the editor — no `p` modal step.
412	for _, r := range "keep going" {
413		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
414	}
415	_, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
416	if msg != "sent to beta-2" || gotID != "beta-2" || gotText != "keep going" {
417		t.Fatalf("send didn't fire correctly: msg=%q id=%q text=%q", msg, gotID, gotText)
418	}
419
420	// Editor must stay mounted after submit (/btw-style), with an
421	// empty buffer ready for the next message.
422	if !d.prompting {
423		t.Error("editor closed after submit; /btw-style transcript view should keep it mounted")
424	}
425	if !d.viewing {
426		t.Error("transcript view collapsed after submit; should stay open")
427	}
428	if d.promptEd != nil && d.promptEd.Value() != "" {
429		t.Errorf("editor buffer not cleared after submit: %q", d.promptEd.Value())
430	}
431}
432
433// TestSwarmDialogPromptEscCancels makes sure Esc inside the prompt
434// editor aborts without invoking the send callback.
435func TestSwarmDialogPromptEscCancels(t *testing.T) {
436	rows := []swarm.AgentSnapshot{{ID: "gamma-3", Status: swarm.StatusRunning, Started: time.Now()}}
437	sends := 0
438	d := newSwarmDialog()
439	d.Open(
440		staticSnapshots(rows...),
441		nil, nil, nil,
442		func(id, text string) error { sends++; return nil },
443		nil,
444		"",
445	)
446	_ = d.Render(tui.Theme{}, 80)
447	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
448	for _, r := range "oops" {
449		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
450	}
451	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
452	if d.prompting {
453		t.Fatal("esc did not close prompt editor")
454	}
455	if sends != 0 {
456		t.Fatalf("send fired despite esc: calls=%d", sends)
457	}
458}
459
460// TestSwarmDialogPWithoutSendCallback verifies that pressing 'p'
461// when no send callback is wired is a silent no-op (mirrors the
462// existing 'n' / no-spawn-callback contract).
463func TestSwarmDialogPWithoutSendCallback(t *testing.T) {
464	rows := []swarm.AgentSnapshot{{ID: "delta-4", Status: swarm.StatusRunning, Started: time.Now()}}
465	d := newSwarmDialog()
466	d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
467	_ = d.Render(tui.Theme{}, 80)
468	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
469	if d.prompting {
470		t.Fatal("p opened prompt editor without a send callback")
471	}
472}
473
474// TestOpenForResumeParksCursorOnFirstResumable verifies the picker
475// entry point: with a mix of running and detached agents, the
476// dialog opens with the cursor on the first detached row (skipping
477// any leading running ones) so the user can press R immediately.
478func TestOpenForResumeParksCursorOnFirstResumable(t *testing.T) {
479	rows := []swarm.AgentSnapshot{
480		{ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},
481		{ID: "live-2", Status: swarm.StatusRunning, Started: time.Now()},
482		{ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()},
483		{ID: "detached-2", Status: swarm.StatusKilled, Started: time.Now()},
484	}
485	d := newSwarmDialog()
486	count := d.OpenForResume(
487		staticSnapshots(rows...),
488		nil, nil, nil, nil,
489		func(id string) error { return nil },
490		"",
491	)
492	if count != 2 {
493		t.Fatalf("resumable count = %d; want 2", count)
494	}
495	if d.cursor != 2 {
496		t.Fatalf("cursor = %d; want 2 (first resumable row)", d.cursor)
497	}
498	if sel := d.selected(); sel == nil || sel.ID != "detached-1" {
499		t.Fatalf("selected = %+v; want detached-1", sel)
500	}
501}
502
503// TestOpenForResumeWithNoResumableLeavesDialogActive ensures the
504// picker still opens the dashboard when nothing is resumable, so the
505// user sees what's there instead of getting silently nothing. The
506// slash handler is responsible for surfacing the "no resumable
507// agents" status string — the dialog itself just opens.
508func TestOpenForResumeWithNoResumableLeavesDialogActive(t *testing.T) {
509	rows := []swarm.AgentSnapshot{
510		{ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},
511	}
512	d := newSwarmDialog()
513	count := d.OpenForResume(
514		staticSnapshots(rows...),
515		nil, nil, nil, nil,
516		func(id string) error { return nil },
517		"",
518	)
519	if count != 0 {
520		t.Fatalf("resumable count = %d; want 0", count)
521	}
522	if !d.Active() {
523		t.Fatal("dialog should remain active so the user sees the empty state")
524	}
525	if d.cursor != 0 {
526		t.Fatalf("cursor = %d; want 0 (no parking)", d.cursor)
527	}
528}
529
530// TestOpenForResumeREnterTriggersResume drives the full picker UX:
531// /swarm resume → dialog opens parked on a resumable row → user
532// presses R → the resume callback fires for that exact id.
533func TestOpenForResumeREnterTriggersResume(t *testing.T) {
534	rows := []swarm.AgentSnapshot{
535		{ID: "live-1", Status: swarm.StatusRunning, Started: time.Now()},
536		{ID: "detached-1", Status: swarm.StatusDetached, Started: time.Now()},
537	}
538	var resumed string
539	d := newSwarmDialog()
540	d.OpenForResume(
541		staticSnapshots(rows...),
542		nil, nil, nil, nil,
543		func(id string) error { resumed = id; return nil },
544		"",
545	)
546	_ = d.Render(tui.Theme{}, 80)
547	_, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'R'})
548	if errMsg != "" {
549		t.Fatalf("unexpected err: %s", errMsg)
550	}
551	if resumed != "detached-1" {
552		t.Fatalf("resume callback got %q; want detached-1", resumed)
553	}
554	if msg != "resumed detached-1" {
555		t.Fatalf("status msg = %q", msg)
556	}
557}
558
559// TestNeedsTickRefresh enumerates the four dialog states so the host's
560// animation tick wakes the renderer for live updates but stays out of
561// the way of the inline editors (which need the cursor blink).
562func TestNeedsTickRefresh(t *testing.T) {
563	// Closed.
564	d := newSwarmDialog()
565	if d.NeedsTickRefresh() {
566		t.Fatal("closed dialog should not request tick refresh")
567	}
568
569	// List view (open, no editors): tick should refresh so rows update.
570	d.Open(staticSnapshots(), nil, nil, nil, nil, nil, "")
571	if !d.NeedsTickRefresh() {
572		t.Fatal("open list view should request tick refresh")
573	}
574
575	// Transcript view: still background-driven; tick should refresh.
576	rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
577	d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
578	_ = d.Render(tui.Theme{}, 80)
579	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
580	if !d.viewing {
581		t.Fatal("setup: enter did not open transcript view")
582	}
583	if !d.NeedsTickRefresh() {
584		t.Fatal("transcript view should request tick refresh")
585	}
586
587	// Spawn editor open: tick should NOT refresh (cursor blink).
588	d = newSwarmDialog()
589	d.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
590	_ = d.Render(tui.Theme{}, 80)
591	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
592	if !d.spawning {
593		t.Fatal("setup: n did not enter spawning mode")
594	}
595	if d.NeedsTickRefresh() {
596		t.Fatal("spawn editor should suppress tick refresh")
597	}
598
599	// Prompt editor open: same suppression.
600	d = newSwarmDialog()
601	rows = []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
602	d.Open(staticSnapshots(rows...), nil, nil, nil, func(string, string) error { return nil }, nil, "")
603	_ = d.Render(tui.Theme{}, 80)
604	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
605	if !d.prompting {
606		t.Fatal("setup: p did not enter prompting mode")
607	}
608	if d.NeedsTickRefresh() {
609		t.Fatal("prompt editor should suppress tick refresh")
610	}
611}
612
613// TestCursorPosPromptEditor verifies the dialog reports a real
614// caret row for the prompt editor in both list-view and
615// transcript-view contexts. Without this, the terminal cursor
616// stayed at the (now-hidden) main editor row when the user
617// pressed 'p', so the visible caret looked wrong.
618func TestCursorPosPromptEditor(t *testing.T) {
619	rows := []swarm.AgentSnapshot{
620		{ID: "a", Task: "t", Status: swarm.StatusRunning, Started: time.Now()},
621	}
622
623	// From the list view.
624	d := newSwarmDialog()
625	d.Open(staticSnapshots(rows...), nil, nil, nil,
626		func(string, string) error { return nil }, nil, "")
627	_ = d.Render(tui.Theme{}, 80)
628	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
629	if !d.prompting {
630		t.Fatal("setup: p did not enter prompting from list view")
631	}
632	row, _ := d.CursorPos(80)
633	if row < 1 {
634		t.Fatalf("list-view prompt CursorPos row = %d; want >= 1", row)
635	}
636
637	// From the transcript view (deeper offset due to metadata block).
638	d = newSwarmDialog()
639	d.Open(staticSnapshots(rows...), nil, nil, nil,
640		func(string, string) error { return nil }, nil, "")
641	_ = d.Render(tui.Theme{}, 80)
642	d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript
643	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
644	if !d.prompting || !d.viewing {
645		t.Fatalf("setup: prompting=%v viewing=%v", d.prompting, d.viewing)
646	}
647	rowT, _ := d.CursorPos(80)
648	if rowT <= row {
649		t.Fatalf("transcript-view prompt row = %d; should be deeper than list row %d", rowT, row)
650	}
651}
652
653// TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression
654// caught by the user's screenshot: pressing 'p' on a detached agent
655// dialled a dead unix socket and printed the raw path as an error.
656// We now block the prompt editor for any non-running status and
657// surface a clear status-bar hint instead.
658func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) {
659	for _, st := range []swarm.Status{
660		swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
661	} {
662		t.Run(string(st), func(t *testing.T) {
663			rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
664			sends := 0
665			d := newSwarmDialog()
666			d.Open(staticSnapshots(rows...), nil, nil, nil,
667				func(string, string) error { sends++; return nil }, nil, "")
668			_ = d.Render(tui.Theme{}, 80)
669
670			_, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
671			if d.prompting {
672				t.Fatal("prompt editor opened on non-running agent")
673			}
674			if sends != 0 {
675				t.Fatal("send callback fired without an open editor")
676			}
677			if msg != "" {
678				t.Errorf("unexpected ok msg: %q", msg)
679			}
680			if !strings.Contains(errMsg, string(st)) {
681				t.Errorf("hint = %q; should mention status %q", errMsg, st)
682			}
683			if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning {
684				t.Errorf("hint = %q; should mention resume for resumable state", errMsg)
685			}
686		})
687	}
688}
689
690// TestSwarmDialogPOnRunningAgentOpensEditor is the positive
691// complement of the previous test: running agents still open the
692// editor normally.
693func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) {
694	rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
695	d := newSwarmDialog()
696	d.Open(staticSnapshots(rows...), nil, nil, nil,
697		func(string, string) error { return nil }, nil, "")
698	_ = d.Render(tui.Theme{}, 80)
699	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
700	if !d.prompting {
701		t.Fatal("prompt editor did not open on running agent")
702	}
703}
704
705// TestFriendlySendErrRewritesNotReady asserts ErrNotReady becomes the
706// user-friendly "isn't accepting input" hint, and unknown errors
707// fall through verbatim.
708func TestFriendlySendErrRewritesNotReady(t *testing.T) {
709	hint := friendlySendErr("alpha-1", swarm.ErrNotReady)
710	if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") {
711		t.Errorf("friendlySendErr(ErrNotReady) = %q", hint)
712	}
713	if strings.Contains(hint, "swarm:") {
714		t.Errorf("friendlySendErr leaked raw error text: %q", hint)
715	}
716
717	other := errors.New("some other failure")
718	got := friendlySendErr("alpha-1", other)
719	if got != "send: some other failure" {
720		t.Errorf("friendlySendErr passthrough = %q", got)
721	}
722}
723
724// TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat
725// transcript log is regrouped into user / assistant / stderr /
726// error blocks before rendering. Each output row should be styled
727// for its role rather than printed verbatim.
728func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
729	lines := []string{
730		"user: hello",
731		"hi there",
732		"second assistant line",
733		"stderr: warning thing",
734		"error: boom",
735	}
736	out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
737	joined := strings.Join(out, "\n")
738
739	// The user echo should appear as a bubble row, which contains
740	// the "▌ " left-bar glyph.
741	if !strings.Contains(joined, "▌ ") {
742		t.Errorf("user echo did not render as a bubble row:\n%s", joined)
743	}
744	// The user prefix should be stripped from the rendered output.
745	if strings.Contains(joined, "user: hello") {
746		t.Errorf("user prefix leaked into rendered output")
747	}
748	// Assistant content survives.
749	if !strings.Contains(joined, "hi there") {
750		t.Errorf("assistant text missing from rendered output")
751	}
752	// Stderr line is prefixed with stderr marker (we keep "stderr"
753	// word in the rendered output for clarity).
754	if !strings.Contains(joined, "stderr") || !strings.Contains(joined, "warning thing") {
755		t.Errorf("stderr block missing:\n%s", joined)
756	}
757	// Error line gets the ✖ marker.
758	if !strings.Contains(joined, "✖ boom") {
759		t.Errorf("error block missing ✖ marker:\n%s", joined)
760	}
761}
762
763// TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the
764// assistant text goes through tui.RenderMarkdown: a literal **bold**
765// pair should become an ANSI bold sequence and bullet "-" should
766// become "•".
767func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {
768	lines := []string{
769		"Here is a list:",
770		"- **bold** item",
771		"- plain item",
772	}
773	out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
774	joined := strings.Join(out, "\n")
775
776	// ANSI bold on/off. Markdown rendering uses CSI 1 / 22.
777	if !strings.Contains(joined, "\x1b[1m") || !strings.Contains(joined, "\x1b[22m") {
778		t.Errorf("expected ANSI bold sequences:\n%q", joined)
779	}
780	// Bullet glyph upgrade.
781	if !strings.Contains(joined, "• ") {
782		t.Errorf("expected • bullet:\n%s", joined)
783	}
784	// Literal "**bold**" markdown source must NOT leak through.
785	if strings.Contains(joined, "**bold**") {
786		t.Errorf("unrendered markdown leaked through:\n%s", joined)
787	}
788}
789
790// TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines
791// guards the contract that consecutive assistant lines form one
792// markdown block. If we render each line in isolation, fenced code
793// (and other multi-line constructs) would never close.
794func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) {
795	lines := []string{
796		"Some prose.",
797		"```go",
798		"package main",
799		"",
800		"func main() {}",
801		"```",
802		"Done.",
803	}
804	out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
805	joined := strings.Join(out, "\n")
806
807	// The literal fence markers should be consumed by the markdown
808	// renderer; if we rendered each line on its own the ``` would
809	// leak as text.
810	if strings.Contains(joined, "```") {
811		t.Errorf("fence markers leaked — lines weren't grouped:\n%s", joined)
812	}
813	// The code content should still be in the output — strip ANSI
814	// before checking because syntax highlighting wraps each token.
815	plain := stripANSIBytes(joined)
816	if !strings.Contains(plain, "package main") || !strings.Contains(plain, "func main") {
817		t.Errorf("code block content missing:\nansi=%q\nplain=%q", joined, plain)
818	}
819}
820
821// TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt-
822// disabled test: pressing 'k' on an already-terminal or detached
823// agent now surfaces a hint instead of routing to Swarm.Stop, which
824// is a no-op for those states (and used to segfault for detached
825// agents before the cancel-nil guard).
826func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) {
827	for _, st := range []swarm.Status{
828		swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
829	} {
830		t.Run(string(st), func(t *testing.T) {
831			rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
832			stops := 0
833			d := newSwarmDialog()
834			d.Open(staticSnapshots(rows...),
835				func(string) error { stops++; return nil },
836				nil, nil, nil, nil, "")
837			_ = d.Render(tui.Theme{}, 80)
838			_, _, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'})
839			if stops != 0 {
840				t.Fatalf("stop callback fired on %s agent", st)
841			}
842			if !strings.Contains(errMsg, string(st)) || !strings.Contains(errMsg, "remove") {
843				t.Errorf("hint = %q; should mention %q and suggest remove", errMsg, st)
844			}
845		})
846	}
847}
848
849// TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn
850// callback receives whatever model the host pinned via
851// SetCurrentModel before opening the dialog. This is how /model is
852// surfaced to swarm agents: the user picks via /model first, then
853// /swarm n, and the new agent inherits.
854func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
855	var gotTask, gotModel, gotProv string
856	d := newSwarmDialog()
857	d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
858	d.Open(staticSnapshots(), nil, nil,
859		func(task, model, prov string) error {
860			gotTask, gotModel, gotProv = task, model, prov
861			return nil
862		},
863		nil, nil, "")
864	_ = d.Render(tui.Theme{}, 80)
865
866	// `n` goes straight to the spawn editor; no picker step.
867	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
868	if !d.spawning {
869		t.Fatal("n did not enter spawning mode")
870	}
871
872	// The spawn editor advertises the inherited model so the user
873	// knows what they're about to spawn against.
874	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
875	if !strings.Contains(out, "model: claude-sonnet-4-5") {
876		t.Errorf("spawn editor missing model banner:\n%s", out)
877	}
878
879	for _, r := range "do thing" {
880		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
881	}
882	_, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
883	if gotTask != "do thing" || gotModel != "claude-sonnet-4-5" || gotProv != "anthropic" {
884		t.Fatalf("spawn args = (%q,%q,%q); want (\"do thing\",\"claude-sonnet-4-5\",\"anthropic\")",
885			gotTask, gotModel, gotProv)
886	}
887	if !strings.Contains(msg, "claude-sonnet-4-5") {
888		t.Errorf("status msg = %q; should mention inherited model", msg)
889	}
890}
891
892// TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in-
893// editor /model command pops the same model dialog the global
894// /model uses, that picking a model captures it as the pending
895// override, and that the spawn editor reopens with any previously-
896// typed task text intact so the user doesn't lose work.
897func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) {
898	var gotTask, gotModel, gotProv string
899	d := newSwarmDialog()
900	d.SetCurrentModel("old-model", "old-prov")
901	d.Open(staticSnapshots(), nil, nil,
902		func(task, model, prov string) error {
903			gotTask, gotModel, gotProv = task, model, prov
904			return nil
905		},
906		nil, nil, "")
907	_ = d.Render(tui.Theme{}, 80)
908
909	// Open spawn editor and type some task text plus a /model line.
910	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
911	if !d.spawning {
912		t.Fatal("n did not enter spawning mode")
913	}
914	for _, r := range "refactor X" {
915		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
916	}
917	// Newline so the editor has a two-line buffer when we add /model.
918	d.newTaskEd.SetValue("refactor X\n/model")
919	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
920
921	if !d.pickingModel {
922		t.Fatalf("/model submit did not open the picker (spawning=%v picking=%v)", d.spawning, d.pickingModel)
923	}
924	if d.spawning {
925		t.Fatal("spawn editor still marked open while picker is up")
926	}
927	if d.spawnDraft != "refactor X" {
928		t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X")
929	}
930
931	// Picker render must take over the dialog frame.
932	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
933	if !strings.Contains(out, "select model for next spawn") {
934		t.Errorf("picker render hint missing:\n%s", out)
935	}
936
937	// Inject a fake selection into the embedded modelDialog and confirm.
938	d.modelPicker.all = []provider.Model{{Provider: "acme", ID: "fake-1"}}
939	d.modelPicker.view = d.modelPicker.all
940	d.modelPicker.cursor = 0
941	_, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
942
943	if d.pickingModel {
944		t.Fatal("picker did not close on select")
945	}
946	if !d.spawning {
947		t.Fatal("spawn editor did not reopen after picker select")
948	}
949	if d.pendingModel != "fake-1" || d.pendingProvider != "acme" {
950		t.Errorf("pending = (%q,%q); want (fake-1, acme)", d.pendingModel, d.pendingProvider)
951	}
952	if !strings.Contains(msg, "fake-1") {
953		t.Errorf("status msg = %q; should mention picked model", msg)
954	}
955	if got := d.newTaskEd.Value(); got != "refactor X" {
956		t.Errorf("reopened editor buffer = %q; want preserved task %q", got, "refactor X")
957	}
958
959	// Now submit the task and confirm the spawn callback gets the
960	// new model.
961	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
962	if gotTask != "refactor X" || gotModel != "fake-1" || gotProv != "acme" {
963		t.Errorf("spawn args = (%q,%q,%q); want (refactor X, fake-1, acme)", gotTask, gotModel, gotProv)
964	}
965}
966
967// TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms
968// pressing Esc inside the picker (no selection) reopens the spawn
969// editor with the previously-typed text intact AND leaves the
970// pending model untouched, so the user can bail out cleanly.
971func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {
972	d := newSwarmDialog()
973	d.SetCurrentModel("keep-me", "keep-prov")
974	d.Open(staticSnapshots(), nil, nil,
975		func(string, string, string) error { return nil },
976		nil, nil, "")
977	_ = d.Render(tui.Theme{}, 80)
978
979	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
980	d.newTaskEd.SetValue("task here\n/model")
981	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
982	if !d.pickingModel {
983		t.Fatal("/model did not open the picker")
984	}
985
986	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
987	if d.pickingModel {
988		t.Fatal("esc did not close the picker")
989	}
990	if !d.spawning {
991		t.Fatal("spawn editor did not reopen after picker cancel")
992	}
993	if d.pendingModel != "keep-me" || d.pendingProvider != "keep-prov" {
994		t.Errorf("pending changed on Esc: (%q,%q); want (keep-me, keep-prov)", d.pendingModel, d.pendingProvider)
995	}
996	if got := d.newTaskEd.Value(); got != "task here" {
997		t.Errorf("reopened editor buffer = %q; want %q", got, "task here")
998	}
999}
1000
1001// TestStripSlashModelLine pins the helper's behaviour: drop any
1002// line whose trimmed content is "/model" or starts with "/model ",
1003// preserve everything else verbatim.
1004func TestStripSlashModelLine(t *testing.T) {
1005	cases := []struct{ in, want string }{
1006		{"/model", ""},
1007		{"  /model  ", ""},
1008		{"foo\n/model", "foo"},
1009		{"/model\nfoo", "foo"},
1010		{"foo\n/model\nbar", "foo\nbar"},
1011		{"/model gpt-5", ""},
1012		{"a /model in prose", "a /model in prose"}, // not first non-blank token → keep
1013		{"", ""},
1014		{"plain task", "plain task"},
1015	}
1016	for _, c := range cases {
1017		if got := stripSlashModelLine(c.in); got != c.want {
1018			t.Errorf("stripSlashModelLine(%q) = %q; want %q", c.in, got, c.want)
1019		}
1020	}
1021}
1022
1023// TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that
1024// when the host doesn't pin a model (SetCurrentModel never called or
1025// called with empty strings), the spawn callback receives empty
1026// model/provider and the child subprocess resolves its own default.
1027func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
1028	var gotModel, gotProv string
1029	d := newSwarmDialog()
1030	d.Open(staticSnapshots(), nil, nil,
1031		func(_, model, prov string) error {
1032			gotModel, gotProv = model, prov
1033			return nil
1034		},
1035		nil, nil, "")
1036	_ = d.Render(tui.Theme{}, 80)
1037
1038	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1039	for _, r := range "do thing" {
1040		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1041	}
1042	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1043	if gotModel != "" || gotProv != "" {
1044		t.Fatalf("spawn args = (model=%q, prov=%q); want both empty", gotModel, gotProv)
1045	}
1046}
1047
1048// TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style
1049// "the transcript grows with the content" property: when the agent
1050// reply lands in the snapshot, the next render includes it in full,
1051// alongside everything that was already there. Replaces the old
1052// followTail/scroll tests that pinned a viewport mechanic we no
1053// longer have.
1054func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {
1055	cur := []swarm.AgentSnapshot{{
1056		ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1057		Lines: []string{"old line 1", "old line 2"}, Started: time.Now(),
1058	}}
1059	snap := func() []swarm.AgentSnapshot { return cur }
1060
1061	d := newSwarmDialog()
1062	d.Open(snap, nil, nil, nil,
1063		func(string, string) error { return nil },
1064		nil, "")
1065	_ = d.Render(tui.Theme{}, 80)
1066	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1067
1068	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1069	if !strings.Contains(out, "old line 1") || !strings.Contains(out, "old line 2") {
1070		t.Fatalf("initial render missing existing lines:\n%s", out)
1071	}
1072
1073	// Agent appends a fresh reply: the very next render must show
1074	// both the new content AND keep the old content (no clipping).
1075	cur = []swarm.AgentSnapshot{{
1076		ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1077		Lines:   []string{"old line 1", "old line 2", "new reply A", "new reply B"},
1078		Started: cur[0].Started,
1079	}}
1080	out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1081	for _, want := range []string{"old line 1", "old line 2", "new reply A", "new reply B"} {
1082		if !strings.Contains(out, want) {
1083			t.Errorf("line %q missing after growth:\n%s", want, out)
1084		}
1085	}
1086}
1087
1088// TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow
1089// for the transcript view: after one Enter-submit the editor stays
1090// mounted and clears, and a second message can be composed and sent
1091// without any modal toggle. Regresses the previous design where
1092// every send closed the prompt and forced the user to press `p`
1093// again.
1094func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) {
1095	rows := []swarm.AgentSnapshot{{
1096		ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),
1097		Lines: []string{"warm-up"},
1098	}}
1099	var sent []string
1100	d := newSwarmDialog()
1101	d.Open(staticSnapshots(rows...), nil, nil, nil,
1102		func(_, text string) error { sent = append(sent, text); return nil },
1103		nil, "")
1104	_ = d.Render(tui.Theme{}, 80)
1105	d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript + auto-editor
1106
1107	// First message.
1108	for _, r := range "first" {
1109		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1110	}
1111	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1112
1113	// Second message, no `p` toggle in between.
1114	for _, r := range "second" {
1115		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1116	}
1117	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1118
1119	if len(sent) != 2 || sent[0] != "first" || sent[1] != "second" {
1120		t.Fatalf("sent = %v; want [first second]", sent)
1121	}
1122	if !d.viewing || !d.prompting {
1123		t.Errorf("transcript view/editor torn down between sends (viewing=%v prompting=%v)", d.viewing, d.prompting)
1124	}
1125}
1126
1127// TestSwarmTranscriptEscClosesView pins the Esc semantics for the
1128// /btw-style transcript view: a single Esc closes the whole view
1129// (back to the dashboard list), regardless of whether the editor
1130// is currently composing. Without this routing the editor's own
1131// Esc handler would just clear the modal and leave the user stuck
1132// inside an empty transcript view.
1133func TestSwarmTranscriptEscClosesView(t *testing.T) {
1134	rows := []swarm.AgentSnapshot{{
1135		ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),
1136		Lines: []string{"hi"},
1137	}}
1138	d := newSwarmDialog()
1139	d.Open(staticSnapshots(rows...), nil, nil, nil,
1140		func(string, string) error { return nil },
1141		nil, "")
1142	_ = d.Render(tui.Theme{}, 80)
1143	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1144	if !d.viewing || !d.prompting {
1145		t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1146	}
1147
1148	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
1149	if d.viewing {
1150		t.Error("Esc did not close the transcript view")
1151	}
1152	if d.prompting {
1153		t.Error("Esc did not tear down the inline editor")
1154	}
1155}
1156
1157// TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net:
1158// transcripts for done / failed / killed agents must NOT auto-open
1159// an editor (the agent's inbox isn't listening, so a send would
1160// just bounce). The view shows the transcript plus a hint about
1161// resuming instead.
1162func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) {
1163	rows := []swarm.AgentSnapshot{{
1164		ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(),
1165		Lines: []string{"all done"},
1166	}}
1167	d := newSwarmDialog()
1168	d.Open(staticSnapshots(rows...), nil, nil, nil,
1169		func(string, string) error { return nil },
1170		nil, "")
1171	_ = d.Render(tui.Theme{}, 80)
1172	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1173	if d.prompting {
1174		t.Fatal("editor auto-opened for a done agent; should only happen for running ones")
1175	}
1176	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1177	if !strings.Contains(out, "prompt: agent is done") {
1178		t.Errorf("hint about non-running status missing:\n%s", out)
1179	}
1180}
1181
1182// TestSwarmTranscriptTickRefreshWhileEditing pins the streaming
1183// fix: with the always-on editor in transcript view, the host's
1184// animation tick MUST still call us so streaming agent output
1185// renders. Without this, sending a message and watching the reply
1186// land silently failed because the prompting=true branch was
1187// suppressing redraws (like the modal editors do).
1188func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) {
1189	rows := []swarm.AgentSnapshot{{
1190		ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1191		Activity: "thinking", Started: time.Now(),
1192		Lines: []string{"hello"},
1193	}}
1194	d := newSwarmDialog()
1195	d.Open(staticSnapshots(rows...), nil, nil, nil,
1196		func(string, string) error { return nil },
1197		nil, "")
1198	_ = d.Render(tui.Theme{}, 80)
1199	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1200	if !d.viewing || !d.prompting {
1201		t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1202	}
1203
1204	if !d.NeedsTickRefresh() {
1205		t.Fatal("transcript view with always-on editor refused tick refresh; streaming output would freeze")
1206	}
1207}
1208
1209// TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse:
1210// the list-view `p` modal (prompting without viewing) still
1211// suppresses tick like the other modal editors, so the cursor
1212// blink works cleanly while composing a one-off prompt.
1213func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) {
1214	rows := []swarm.AgentSnapshot{{
1215		ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(),
1216	}}
1217	d := newSwarmDialog()
1218	d.Open(staticSnapshots(rows...), nil, nil, nil,
1219		func(string, string) error { return nil },
1220		nil, "")
1221	_ = d.Render(tui.Theme{}, 80)
1222	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
1223	if d.viewing || !d.prompting {
1224		t.Fatalf("setup: viewing=%v prompting=%v; want list-view modal", d.viewing, d.prompting)
1225	}
1226
1227	if d.NeedsTickRefresh() {
1228		t.Error("list-view prompt modal did not suppress tick refresh; cursor blink may misbehave")
1229	}
1230}
1231
1232// TestSwarmTranscriptBusySpinnerRenders proves the inline spinner
1233// appears above the always-on editor while the agent is busy and
1234// disappears when it goes idle. This is the /btw-style "something
1235// is happening" feedback the user was missing.
1236func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) {
1237	cur := []swarm.AgentSnapshot{{
1238		ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1239		Activity: "thinking", Started: time.Now(),
1240		Lines: []string{"hello"},
1241	}}
1242	snap := func() []swarm.AgentSnapshot { return cur }
1243	d := newSwarmDialog()
1244	d.Open(snap, nil, nil, nil,
1245		func(string, string) error { return nil },
1246		nil, "")
1247	_ = d.Render(tui.Theme{}, 80)
1248	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1249
1250	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1251	if !strings.Contains(out, "thinking") {
1252		t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out)
1253	}
1254	if d.transcriptSpin == nil {
1255		t.Error("spinner not initialised while agent is busy")
1256	}
1257
1258	// Flip the activity to idle: spinner should vanish.
1259	cur[0].Activity = "idle"
1260	out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1261	if d.transcriptSpin != nil {
1262		t.Error("spinner stayed live after agent reported idle")
1263	}
1264	// The bare word "thinking" must not appear anymore.
1265	if strings.Contains(out, "· thinking, ") {
1266		t.Errorf("busy line still rendered when agent is idle:\n%s", out)
1267	}
1268}
1269
1270func TestStatusLabelCoversAll(t *testing.T) {
1271	for _, s := range []swarm.Status{
1272		swarm.StatusPending, swarm.StatusRunning,
1273		swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
1274	} {
1275		if statusLabel(s) == "" {
1276			t.Fatalf("no label for %s", s)
1277		}
1278	}
1279}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close