v0.14.0 75d4151

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

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