v0.2.1 5470345

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/packages/agent/swarm"
13	"github.com/patriceckhart/zot/packages/provider"
14	"github.com/patriceckhart/zot/packages/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
653func TestCursorPosSpawnEditorWithInheritedModelMatchesPaddedRender(t *testing.T) {
654	d := newSwarmDialog()
655	d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
656	d.Open(staticSnapshots(), nil, nil, func(task, model, provider string) error { return nil }, nil, nil, "")
657	_ = d.Render(tui.Theme{}, 80)
658	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
659	if !d.spawning {
660		t.Fatal("setup: n did not enter spawning mode")
661	}
662
663	row, _ := d.CursorPos(80)
664	lines := padDialogFrame(d.Render(tui.Theme{}, 80))
665	if row < 0 || row >= len(lines) {
666		t.Fatalf("spawn CursorPos row = %d outside rendered lines %d", row, len(lines))
667	}
668	if got := stripANSIBytes(lines[row]); !strings.Contains(got, "▌") {
669		t.Fatalf("spawn CursorPos row %d = %q; want editor prompt row", row, got)
670	}
671}
672
673// TestSwarmDialogPOnNonRunningAgentShowsHint covers the regression
674// caught by the user's screenshot: pressing 'p' on a detached agent
675// dialled a dead unix socket and printed the raw path as an error.
676// We now block the prompt editor for any non-running status and
677// surface a clear status-bar hint instead.
678func TestSwarmDialogPOnNonRunningAgentShowsHint(t *testing.T) {
679	for _, st := range []swarm.Status{
680		swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
681	} {
682		t.Run(string(st), func(t *testing.T) {
683			rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
684			sends := 0
685			d := newSwarmDialog()
686			d.Open(staticSnapshots(rows...), nil, nil, nil,
687				func(string, string) error { sends++; return nil }, nil, "")
688			_ = d.Render(tui.Theme{}, 80)
689
690			_, msg, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
691			if d.prompting {
692				t.Fatal("prompt editor opened on non-running agent")
693			}
694			if sends != 0 {
695				t.Fatal("send callback fired without an open editor")
696			}
697			if msg != "" {
698				t.Errorf("unexpected ok msg: %q", msg)
699			}
700			if !strings.Contains(errMsg, string(st)) {
701				t.Errorf("hint = %q; should mention status %q", errMsg, st)
702			}
703			if !strings.Contains(errMsg, "resume") && st != swarm.StatusPending && st != swarm.StatusRunning {
704				t.Errorf("hint = %q; should mention resume for resumable state", errMsg)
705			}
706		})
707	}
708}
709
710// TestSwarmDialogPOnRunningAgentOpensEditor is the positive
711// complement of the previous test: running agents still open the
712// editor normally.
713func TestSwarmDialogPOnRunningAgentOpensEditor(t *testing.T) {
714	rows := []swarm.AgentSnapshot{{ID: "a", Status: swarm.StatusRunning, Started: time.Now()}}
715	d := newSwarmDialog()
716	d.Open(staticSnapshots(rows...), nil, nil, nil,
717		func(string, string) error { return nil }, nil, "")
718	_ = d.Render(tui.Theme{}, 80)
719	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
720	if !d.prompting {
721		t.Fatal("prompt editor did not open on running agent")
722	}
723}
724
725// TestFriendlySendErrRewritesNotReady asserts ErrNotReady becomes the
726// user-friendly "isn't accepting input" hint, and unknown errors
727// fall through verbatim.
728func TestFriendlySendErrRewritesNotReady(t *testing.T) {
729	hint := friendlySendErr("alpha-1", swarm.ErrNotReady)
730	if !strings.Contains(hint, "alpha-1") || !strings.Contains(hint, "resume") {
731		t.Errorf("friendlySendErr(ErrNotReady) = %q", hint)
732	}
733	if strings.Contains(hint, "swarm:") {
734		t.Errorf("friendlySendErr leaked raw error text: %q", hint)
735	}
736
737	other := errors.New("some other failure")
738	got := friendlySendErr("alpha-1", other)
739	if got != "send: some other failure" {
740		t.Errorf("friendlySendErr passthrough = %q", got)
741	}
742}
743
744// TestRenderSwarmTranscriptBlocks_GroupsByRole asserts the flat
745// transcript log is regrouped into user / assistant / stderr /
746// error blocks before rendering. Each output row should be styled
747// for its role rather than printed verbatim.
748func TestRenderSwarmTranscriptBlocks_GroupsByRole(t *testing.T) {
749	lines := []string{
750		"user: hello",
751		"hi there",
752		"second assistant line",
753		"stderr: warning thing",
754		"error: boom",
755	}
756	out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
757	joined := strings.Join(out, "\n")
758
759	// The user echo should appear as a bubble row, which contains
760	// the "▌ " left-bar glyph.
761	if !strings.Contains(joined, "▌ ") {
762		t.Errorf("user echo did not render as a bubble row:\n%s", joined)
763	}
764	// The user prefix should be stripped from the rendered output.
765	if strings.Contains(joined, "user: hello") {
766		t.Errorf("user prefix leaked into rendered output")
767	}
768	// Assistant content survives.
769	if !strings.Contains(joined, "hi there") {
770		t.Errorf("assistant text missing from rendered output")
771	}
772	// Stderr line is prefixed with stderr marker (we keep "stderr"
773	// word in the rendered output for clarity).
774	if !strings.Contains(joined, "stderr") || !strings.Contains(joined, "warning thing") {
775		t.Errorf("stderr block missing:\n%s", joined)
776	}
777	// Error line gets the ✖ marker.
778	if !strings.Contains(joined, "✖ boom") {
779		t.Errorf("error block missing ✖ marker:\n%s", joined)
780	}
781}
782
783// TestRenderSwarmTranscriptBlocks_MarkdownStyling proves the
784// assistant text goes through tui.RenderMarkdown: a literal **bold**
785// pair should become an ANSI bold sequence and bullet "-" should
786// become "•".
787func TestRenderSwarmTranscriptBlocks_MarkdownStyling(t *testing.T) {
788	lines := []string{
789		"Here is a list:",
790		"- **bold** item",
791		"- plain item",
792	}
793	out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
794	joined := strings.Join(out, "\n")
795
796	// ANSI bold on/off. Markdown rendering uses CSI 1 / 22.
797	if !strings.Contains(joined, "\x1b[1m") || !strings.Contains(joined, "\x1b[22m") {
798		t.Errorf("expected ANSI bold sequences:\n%q", joined)
799	}
800	// Bullet glyph upgrade.
801	if !strings.Contains(joined, "• ") {
802		t.Errorf("expected • bullet:\n%s", joined)
803	}
804	// Literal "**bold**" markdown source must NOT leak through.
805	if strings.Contains(joined, "**bold**") {
806		t.Errorf("unrendered markdown leaked through:\n%s", joined)
807	}
808}
809
810// TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines
811// guards the contract that consecutive assistant lines form one
812// markdown block. If we render each line in isolation, fenced code
813// (and other multi-line constructs) would never close.
814func TestRenderSwarmTranscriptBlocks_GroupsAdjacentAssistantLines(t *testing.T) {
815	lines := []string{
816		"Some prose.",
817		"```go",
818		"package main",
819		"",
820		"func main() {}",
821		"```",
822		"Done.",
823	}
824	out := renderSwarmTranscriptBlocks(lines, tui.Theme{}, 80)
825	joined := strings.Join(out, "\n")
826
827	// The literal fence markers should be consumed by the markdown
828	// renderer; if we rendered each line on its own the ``` would
829	// leak as text.
830	if strings.Contains(joined, "```") {
831		t.Errorf("fence markers leaked — lines weren't grouped:\n%s", joined)
832	}
833	// The code content should still be in the output — strip ANSI
834	// before checking because syntax highlighting wraps each token.
835	plain := stripANSIBytes(joined)
836	if !strings.Contains(plain, "package main") || !strings.Contains(plain, "func main") {
837		t.Errorf("code block content missing:\nansi=%q\nplain=%q", joined, plain)
838	}
839}
840
841// TestSwarmDialogKOnNonRunningAgentShowsHint mirrors the prompt-
842// disabled test: pressing 'k' on an already-terminal or detached
843// agent now surfaces a hint instead of routing to Swarm.Stop, which
844// is a no-op for those states (and used to segfault for detached
845// agents before the cancel-nil guard).
846func TestSwarmDialogKOnNonRunningAgentShowsHint(t *testing.T) {
847	for _, st := range []swarm.Status{
848		swarm.StatusDetached, swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
849	} {
850		t.Run(string(st), func(t *testing.T) {
851			rows := []swarm.AgentSnapshot{{ID: "a", Status: st, Started: time.Now()}}
852			stops := 0
853			d := newSwarmDialog()
854			d.Open(staticSnapshots(rows...),
855				func(string) error { stops++; return nil },
856				nil, nil, nil, nil, "")
857			_ = d.Render(tui.Theme{}, 80)
858			_, _, errMsg := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'k'})
859			if stops != 0 {
860				t.Fatalf("stop callback fired on %s agent", st)
861			}
862			if !strings.Contains(errMsg, string(st)) || !strings.Contains(errMsg, "remove") {
863				t.Errorf("hint = %q; should mention %q and suggest remove", errMsg, st)
864			}
865		})
866	}
867}
868
869// TestSwarmDialogSpawnFlow_CurrentModelInherited proves the spawn
870// callback receives whatever model the host pinned via
871// SetCurrentModel before opening the dialog. This is how /model is
872// surfaced to swarm agents: the user picks via /model first, then
873// /swarm n, and the new agent inherits.
874func TestSwarmDialogSpawnFlow_CurrentModelInherited(t *testing.T) {
875	var gotTask, gotModel, gotProv string
876	d := newSwarmDialog()
877	d.SetCurrentModel("claude-sonnet-4-5", "anthropic")
878	d.Open(staticSnapshots(), nil, nil,
879		func(task, model, prov string) error {
880			gotTask, gotModel, gotProv = task, model, prov
881			return nil
882		},
883		nil, nil, "")
884	_ = d.Render(tui.Theme{}, 80)
885
886	// `n` goes straight to the spawn editor; no picker step.
887	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
888	if !d.spawning {
889		t.Fatal("n did not enter spawning mode")
890	}
891
892	// The spawn editor advertises the inherited model so the user
893	// knows what they're about to spawn against.
894	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
895	if !strings.Contains(out, "model: claude-sonnet-4-5") {
896		t.Errorf("spawn editor missing model banner:\n%s", out)
897	}
898
899	for _, r := range "do thing" {
900		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
901	}
902	_, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
903	if gotTask != "do thing" || gotModel != "claude-sonnet-4-5" || gotProv != "anthropic" {
904		t.Fatalf("spawn args = (%q,%q,%q); want (\"do thing\",\"claude-sonnet-4-5\",\"anthropic\")",
905			gotTask, gotModel, gotProv)
906	}
907	if !strings.Contains(msg, "claude-sonnet-4-5") {
908		t.Errorf("status msg = %q; should mention inherited model", msg)
909	}
910}
911
912// TestSwarmDialogSpawnEditor_SlashModelOpensPicker proves the in-
913// editor /model command pops the same model dialog the global
914// /model uses, that picking a model captures it as the pending
915// override, and that the spawn editor reopens with any previously-
916// typed task text intact so the user doesn't lose work.
917func TestSwarmDialogSpawnEditor_SlashModelOpensPicker(t *testing.T) {
918	var gotTask, gotModel, gotProv string
919	d := newSwarmDialog()
920	d.SetCurrentModel("old-model", "old-prov")
921	d.Open(staticSnapshots(), nil, nil,
922		func(task, model, prov string) error {
923			gotTask, gotModel, gotProv = task, model, prov
924			return nil
925		},
926		nil, nil, "")
927	_ = d.Render(tui.Theme{}, 80)
928
929	// Open spawn editor and type some task text plus a /model line.
930	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
931	if !d.spawning {
932		t.Fatal("n did not enter spawning mode")
933	}
934	for _, r := range "refactor X" {
935		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
936	}
937	// Newline so the editor has a two-line buffer when we add /model.
938	d.newTaskEd.SetValue("refactor X\n/model")
939	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
940
941	if !d.pickingModel {
942		t.Fatalf("/model submit did not open the picker (spawning=%v picking=%v)", d.spawning, d.pickingModel)
943	}
944	if d.spawning {
945		t.Fatal("spawn editor still marked open while picker is up")
946	}
947	if d.spawnDraft != "refactor X" {
948		t.Fatalf("spawnDraft = %q; want %q (the /model line should be stripped, the task kept)", d.spawnDraft, "refactor X")
949	}
950
951	// Picker render must take over the dialog frame.
952	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
953	if !strings.Contains(out, "select model for next spawn") {
954		t.Errorf("picker render hint missing:\n%s", out)
955	}
956
957	// Inject a fake selection into the embedded modelDialog and confirm.
958	d.modelPicker.all = []provider.Model{{Provider: "acme", ID: "fake-1"}}
959	d.modelPicker.view = d.modelPicker.all
960	d.modelPicker.cursor = 0
961	_, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
962
963	if d.pickingModel {
964		t.Fatal("picker did not close on select")
965	}
966	if !d.spawning {
967		t.Fatal("spawn editor did not reopen after picker select")
968	}
969	if d.pendingModel != "fake-1" || d.pendingProvider != "acme" {
970		t.Errorf("pending = (%q,%q); want (fake-1, acme)", d.pendingModel, d.pendingProvider)
971	}
972	if !strings.Contains(msg, "fake-1") {
973		t.Errorf("status msg = %q; should mention picked model", msg)
974	}
975	if got := d.newTaskEd.Value(); got != "refactor X" {
976		t.Errorf("reopened editor buffer = %q; want preserved task %q", got, "refactor X")
977	}
978
979	// Now submit the task and confirm the spawn callback gets the
980	// new model.
981	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
982	if gotTask != "refactor X" || gotModel != "fake-1" || gotProv != "acme" {
983		t.Errorf("spawn args = (%q,%q,%q); want (refactor X, fake-1, acme)", gotTask, gotModel, gotProv)
984	}
985}
986
987// TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft confirms
988// pressing Esc inside the picker (no selection) reopens the spawn
989// editor with the previously-typed text intact AND leaves the
990// pending model untouched, so the user can bail out cleanly.
991func TestSwarmDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {
992	d := newSwarmDialog()
993	d.SetCurrentModel("keep-me", "keep-prov")
994	d.Open(staticSnapshots(), nil, nil,
995		func(string, string, string) error { return nil },
996		nil, nil, "")
997	_ = d.Render(tui.Theme{}, 80)
998
999	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1000	d.newTaskEd.SetValue("task here\n/model")
1001	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1002	if !d.pickingModel {
1003		t.Fatal("/model did not open the picker")
1004	}
1005
1006	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
1007	if d.pickingModel {
1008		t.Fatal("esc did not close the picker")
1009	}
1010	if !d.spawning {
1011		t.Fatal("spawn editor did not reopen after picker cancel")
1012	}
1013	if d.pendingModel != "keep-me" || d.pendingProvider != "keep-prov" {
1014		t.Errorf("pending changed on Esc: (%q,%q); want (keep-me, keep-prov)", d.pendingModel, d.pendingProvider)
1015	}
1016	if got := d.newTaskEd.Value(); got != "task here" {
1017		t.Errorf("reopened editor buffer = %q; want %q", got, "task here")
1018	}
1019}
1020
1021// TestStripSlashModelLine pins the helper's behaviour: drop any
1022// line whose trimmed content is "/model" or starts with "/model ",
1023// preserve everything else verbatim.
1024func TestStripSlashModelLine(t *testing.T) {
1025	cases := []struct{ in, want string }{
1026		{"/model", ""},
1027		{"  /model  ", ""},
1028		{"foo\n/model", "foo"},
1029		{"/model\nfoo", "foo"},
1030		{"foo\n/model\nbar", "foo\nbar"},
1031		{"/model gpt-5", ""},
1032		{"a /model in prose", "a /model in prose"}, // not first non-blank token → keep
1033		{"", ""},
1034		{"plain task", "plain task"},
1035	}
1036	for _, c := range cases {
1037		if got := stripSlashModelLine(c.in); got != c.want {
1038			t.Errorf("stripSlashModelLine(%q) = %q; want %q", c.in, got, c.want)
1039		}
1040	}
1041}
1042
1043// TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault confirms that
1044// when the host doesn't pin a model (SetCurrentModel never called or
1045// called with empty strings), the spawn callback receives empty
1046// model/provider and the child subprocess resolves its own default.
1047func TestSwarmDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
1048	var gotModel, gotProv string
1049	d := newSwarmDialog()
1050	d.Open(staticSnapshots(), nil, nil,
1051		func(_, model, prov string) error {
1052			gotModel, gotProv = model, prov
1053			return nil
1054		},
1055		nil, nil, "")
1056	_ = d.Render(tui.Theme{}, 80)
1057
1058	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1059	for _, r := range "do thing" {
1060		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1061	}
1062	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1063	if gotModel != "" || gotProv != "" {
1064		t.Fatalf("spawn args = (model=%q, prov=%q); want both empty", gotModel, gotProv)
1065	}
1066}
1067
1068// TestSwarmTranscriptGrowsWithNewMessages confirms the /btw-style
1069// "the transcript grows with the content" property: when the agent
1070// reply lands in the snapshot, the next render includes it in full,
1071// alongside everything that was already there. Replaces the old
1072// followTail/scroll tests that pinned a viewport mechanic we no
1073// longer have.
1074func TestSwarmTranscriptGrowsWithNewMessages(t *testing.T) {
1075	cur := []swarm.AgentSnapshot{{
1076		ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1077		Lines: []string{"old line 1", "old line 2"}, Started: time.Now(),
1078	}}
1079	snap := func() []swarm.AgentSnapshot { return cur }
1080
1081	d := newSwarmDialog()
1082	d.Open(snap, nil, nil, nil,
1083		func(string, string) error { return nil },
1084		nil, "")
1085	_ = d.Render(tui.Theme{}, 80)
1086	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1087
1088	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1089	if !strings.Contains(out, "old line 1") || !strings.Contains(out, "old line 2") {
1090		t.Fatalf("initial render missing existing lines:\n%s", out)
1091	}
1092
1093	// Agent appends a fresh reply: the very next render must show
1094	// both the new content AND keep the old content (no clipping).
1095	cur = []swarm.AgentSnapshot{{
1096		ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1097		Lines:   []string{"old line 1", "old line 2", "new reply A", "new reply B"},
1098		Started: cur[0].Started,
1099	}}
1100	out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1101	for _, want := range []string{"old line 1", "old line 2", "new reply A", "new reply B"} {
1102		if !strings.Contains(out, want) {
1103			t.Errorf("line %q missing after growth:\n%s", want, out)
1104		}
1105	}
1106}
1107
1108// TestSwarmTranscriptBtwStyleMultipleSends pins the /btw-style flow
1109// for the transcript view: after one Enter-submit the editor stays
1110// mounted and clears, and a second message can be composed and sent
1111// without any modal toggle. Regresses the previous design where
1112// every send closed the prompt and forced the user to press `p`
1113// again.
1114func TestSwarmTranscriptBtwStyleMultipleSends(t *testing.T) {
1115	rows := []swarm.AgentSnapshot{{
1116		ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),
1117		Lines: []string{"warm-up"},
1118	}}
1119	var sent []string
1120	d := newSwarmDialog()
1121	d.Open(staticSnapshots(rows...), nil, nil, nil,
1122		func(_, text string) error { sent = append(sent, text); return nil },
1123		nil, "")
1124	_ = d.Render(tui.Theme{}, 80)
1125	d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript + auto-editor
1126
1127	// First message.
1128	for _, r := range "first" {
1129		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1130	}
1131	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1132
1133	// Second message, no `p` toggle in between.
1134	for _, r := range "second" {
1135		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1136	}
1137	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1138
1139	if len(sent) != 2 || sent[0] != "first" || sent[1] != "second" {
1140		t.Fatalf("sent = %v; want [first second]", sent)
1141	}
1142	if !d.viewing || !d.prompting {
1143		t.Errorf("transcript view/editor torn down between sends (viewing=%v prompting=%v)", d.viewing, d.prompting)
1144	}
1145}
1146
1147// TestSwarmTranscriptEscClosesView pins the Esc semantics for the
1148// /btw-style transcript view: a single Esc closes the whole view
1149// (back to the dashboard list), regardless of whether the editor
1150// is currently composing. Without this routing the editor's own
1151// Esc handler would just clear the modal and leave the user stuck
1152// inside an empty transcript view.
1153func TestSwarmTranscriptEscClosesView(t *testing.T) {
1154	rows := []swarm.AgentSnapshot{{
1155		ID: "agent-1", Task: "x", Status: swarm.StatusRunning, Started: time.Now(),
1156		Lines: []string{"hi"},
1157	}}
1158	d := newSwarmDialog()
1159	d.Open(staticSnapshots(rows...), nil, nil, nil,
1160		func(string, string) error { return nil },
1161		nil, "")
1162	_ = d.Render(tui.Theme{}, 80)
1163	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1164	if !d.viewing || !d.prompting {
1165		t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1166	}
1167
1168	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
1169	if d.viewing {
1170		t.Error("Esc did not close the transcript view")
1171	}
1172	if d.prompting {
1173		t.Error("Esc did not tear down the inline editor")
1174	}
1175}
1176
1177// TestSwarmTranscriptNonRunningAgentNoEditor pins the safety net:
1178// transcripts for done / failed / killed agents must NOT auto-open
1179// an editor (the agent's inbox isn't listening, so a send would
1180// just bounce). The view shows the transcript plus a hint about
1181// resuming instead.
1182func TestSwarmTranscriptNonRunningAgentNoEditor(t *testing.T) {
1183	rows := []swarm.AgentSnapshot{{
1184		ID: "agent-1", Task: "x", Status: swarm.StatusDone, Started: time.Now(),
1185		Lines: []string{"all done"},
1186	}}
1187	d := newSwarmDialog()
1188	d.Open(staticSnapshots(rows...), nil, nil, nil,
1189		func(string, string) error { return nil },
1190		nil, "")
1191	_ = d.Render(tui.Theme{}, 80)
1192	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1193	if d.prompting {
1194		t.Fatal("editor auto-opened for a done agent; should only happen for running ones")
1195	}
1196	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1197	if !strings.Contains(out, "prompt: agent is done") {
1198		t.Errorf("hint about non-running status missing:\n%s", out)
1199	}
1200}
1201
1202// TestSwarmTranscriptTickRefreshWhileEditing pins the streaming
1203// fix: with the always-on editor in transcript view, the host's
1204// animation tick MUST still call us so streaming agent output
1205// renders. Without this, sending a message and watching the reply
1206// land silently failed because the prompting=true branch was
1207// suppressing redraws (like the modal editors do).
1208func TestSwarmTranscriptTickRefreshWhileEditing(t *testing.T) {
1209	rows := []swarm.AgentSnapshot{{
1210		ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1211		Activity: "thinking", Started: time.Now(),
1212		Lines: []string{"hello"},
1213	}}
1214	d := newSwarmDialog()
1215	d.Open(staticSnapshots(rows...), nil, nil, nil,
1216		func(string, string) error { return nil },
1217		nil, "")
1218	_ = d.Render(tui.Theme{}, 80)
1219	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1220	if !d.viewing || !d.prompting {
1221		t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1222	}
1223
1224	if !d.NeedsTickRefresh() {
1225		t.Fatal("transcript view with always-on editor refused tick refresh; streaming output would freeze")
1226	}
1227}
1228
1229// TestSwarmTranscriptTickRefreshModalSuppressed pins the inverse:
1230// the list-view `p` modal (prompting without viewing) still
1231// suppresses tick like the other modal editors, so the cursor
1232// blink works cleanly while composing a one-off prompt.
1233func TestSwarmTranscriptTickRefreshModalSuppressed(t *testing.T) {
1234	rows := []swarm.AgentSnapshot{{
1235		ID: "agent-1", Status: swarm.StatusRunning, Started: time.Now(),
1236	}}
1237	d := newSwarmDialog()
1238	d.Open(staticSnapshots(rows...), nil, nil, nil,
1239		func(string, string) error { return nil },
1240		nil, "")
1241	_ = d.Render(tui.Theme{}, 80)
1242	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
1243	if d.viewing || !d.prompting {
1244		t.Fatalf("setup: viewing=%v prompting=%v; want list-view modal", d.viewing, d.prompting)
1245	}
1246
1247	if d.NeedsTickRefresh() {
1248		t.Error("list-view prompt modal did not suppress tick refresh; cursor blink may misbehave")
1249	}
1250}
1251
1252// TestSwarmTranscriptBusySpinnerRenders proves the inline spinner
1253// appears above the always-on editor while the agent is busy and
1254// disappears when it goes idle. This is the /btw-style "something
1255// is happening" feedback the user was missing.
1256func TestSwarmTranscriptBusySpinnerRenders(t *testing.T) {
1257	cur := []swarm.AgentSnapshot{{
1258		ID: "agent-1", Task: "x", Status: swarm.StatusRunning,
1259		Activity: "thinking", Started: time.Now(),
1260		Lines: []string{"hello"},
1261	}}
1262	snap := func() []swarm.AgentSnapshot { return cur }
1263	d := newSwarmDialog()
1264	d.Open(snap, nil, nil, nil,
1265		func(string, string) error { return nil },
1266		nil, "")
1267	_ = d.Render(tui.Theme{}, 80)
1268	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1269
1270	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1271	if !strings.Contains(out, "thinking") {
1272		t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out)
1273	}
1274	if d.transcriptSpin == nil {
1275		t.Error("spinner not initialised while agent is busy")
1276	}
1277
1278	// Flip the activity to idle: spinner should vanish.
1279	cur[0].Activity = "idle"
1280	out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1281	if d.transcriptSpin != nil {
1282		t.Error("spinner stayed live after agent reported idle")
1283	}
1284	// The bare word "thinking" must not appear anymore.
1285	if strings.Contains(out, "· thinking, ") {
1286		t.Errorf("busy line still rendered when agent is idle:\n%s", out)
1287	}
1288}
1289
1290func TestStatusLabelCoversAll(t *testing.T) {
1291	for _, s := range []swarm.Status{
1292		swarm.StatusPending, swarm.StatusRunning,
1293		swarm.StatusDone, swarm.StatusFailed, swarm.StatusKilled,
1294	} {
1295		if statusLabel(s) == "" {
1296			t.Fatalf("no label for %s", s)
1297		}
1298	}
1299}

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