v0.6.0 01ec125

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
982	// Picker render must take over the dialog frame.
983	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
984	if !strings.Contains(out, "select model for next spawn") {
985		t.Errorf("picker render hint missing:\n%s", out)
986	}
987
988	// Inject a fake selection into the embedded modelDialog and confirm.
989	d.modelPicker.all = []provider.Model{{Provider: "acme", ID: "fake-1"}}
990	d.modelPicker.view = d.modelPicker.all
991	d.modelPicker.cursor = 0
992	_, msg, _ := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
993
994	if d.pickingModel {
995		t.Fatal("picker did not close on select")
996	}
997	if !d.spawning {
998		t.Fatal("spawn editor did not reopen after picker select")
999	}
1000	if d.pendingModel != "fake-1" || d.pendingProvider != "acme" {
1001		t.Errorf("pending = (%q,%q); want (fake-1, acme)", d.pendingModel, d.pendingProvider)
1002	}
1003	if !strings.Contains(msg, "fake-1") {
1004		t.Errorf("status msg = %q; should mention picked model", msg)
1005	}
1006	if got := d.newTaskEd.Value(); got != "refactor X" {
1007		t.Errorf("reopened editor buffer = %q; want preserved task %q", got, "refactor X")
1008	}
1009
1010	// Now submit the task and confirm the spawn callback gets the
1011	// new model.
1012	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1013	if gotTask != "refactor X" || gotModel != "fake-1" || gotProv != "acme" {
1014		t.Errorf("spawn args = (%q,%q,%q); want (refactor X, fake-1, acme)", gotTask, gotModel, gotProv)
1015	}
1016}
1017
1018// TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft confirms
1019// pressing Esc inside the picker (no selection) reopens the spawn
1020// editor with the previously-typed text intact AND leaves the
1021// pending model untouched, so the user can bail out cleanly.
1022func TestSubagentsDialogSpawnEditor_SlashModelEscRestoresDraft(t *testing.T) {
1023	d := newSubagentsDialog()
1024	d.SetCurrentModel("keep-me", "keep-prov")
1025	d.Open(staticSnapshots(), nil, nil,
1026		func(string, string, string) error { return nil },
1027		nil, nil, "")
1028	_ = d.Render(tui.Theme{}, 80)
1029
1030	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1031	d.newTaskEd.SetValue("task here\n/model")
1032	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1033	if !d.pickingModel {
1034		t.Fatal("/model did not open the picker")
1035	}
1036
1037	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
1038	if d.pickingModel {
1039		t.Fatal("esc did not close the picker")
1040	}
1041	if !d.spawning {
1042		t.Fatal("spawn editor did not reopen after picker cancel")
1043	}
1044	if d.pendingModel != "keep-me" || d.pendingProvider != "keep-prov" {
1045		t.Errorf("pending changed on Esc: (%q,%q); want (keep-me, keep-prov)", d.pendingModel, d.pendingProvider)
1046	}
1047	if got := d.newTaskEd.Value(); got != "task here" {
1048		t.Errorf("reopened editor buffer = %q; want %q", got, "task here")
1049	}
1050}
1051
1052// TestStripSlashModelLine pins the helper's behaviour: drop any
1053// line whose trimmed content is "/model" or starts with "/model ",
1054// preserve everything else verbatim.
1055func TestStripSlashModelLine(t *testing.T) {
1056	cases := []struct{ in, want string }{
1057		{"/model", ""},
1058		{"  /model  ", ""},
1059		{"foo\n/model", "foo"},
1060		{"/model\nfoo", "foo"},
1061		{"foo\n/model\nbar", "foo\nbar"},
1062		{"/model gpt-5", ""},
1063		{"/MODEL GPT-5", ""},
1064		{"a /model in prose", "a /model in prose"}, // not first non-blank token → keep
1065		{"", ""},
1066		{"plain task", "plain task"},
1067	}
1068	for _, c := range cases {
1069		if got := stripSlashModelLine(c.in); got != c.want {
1070			t.Errorf("stripSlashModelLine(%q) = %q; want %q", c.in, got, c.want)
1071		}
1072	}
1073}
1074
1075// TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault confirms that
1076// when the host doesn't pin a model (SetCurrentModel never called or
1077// called with empty strings), the spawn callback receives empty
1078// model/provider and the child subprocess resolves its own default.
1079func TestSubagentsDialogSpawnFlow_NoModelInheritsChildDefault(t *testing.T) {
1080	var gotModel, gotProv string
1081	d := newSubagentsDialog()
1082	d.Open(staticSnapshots(), nil, nil,
1083		func(_, model, prov string) error {
1084			gotModel, gotProv = model, prov
1085			return nil
1086		},
1087		nil, nil, "")
1088	_ = d.Render(tui.Theme{}, 80)
1089
1090	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
1091	for _, r := range "do thing" {
1092		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1093	}
1094	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1095	if gotModel != "" || gotProv != "" {
1096		t.Fatalf("spawn args = (model=%q, prov=%q); want both empty", gotModel, gotProv)
1097	}
1098}
1099
1100// TestSupervisorTranscriptGrowsWithNewMessages confirms the /btw-style
1101// "the transcript grows with the content" property: when the agent
1102// reply lands in the snapshot, the next render includes it in full,
1103// alongside everything that was already there. Replaces the old
1104// followTail/scroll tests that pinned a viewport mechanic we no
1105// longer have.
1106func TestSupervisorTranscriptGrowsWithNewMessages(t *testing.T) {
1107	cur := []subagents.AgentSnapshot{{
1108		ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1109		Lines: []string{"old line 1", "old line 2"}, Started: time.Now(),
1110	}}
1111	snap := func() []subagents.AgentSnapshot { return cur }
1112
1113	d := newSubagentsDialog()
1114	d.Open(snap, nil, nil, nil,
1115		func(string, string) error { return nil },
1116		nil, "")
1117	_ = d.Render(tui.Theme{}, 80)
1118	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1119
1120	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1121	if !strings.Contains(out, "old line 1") || !strings.Contains(out, "old line 2") {
1122		t.Fatalf("initial render missing existing lines:\n%s", out)
1123	}
1124
1125	// Agent appends a fresh reply: the very next render must show
1126	// both the new content AND keep the old content (no clipping).
1127	cur = []subagents.AgentSnapshot{{
1128		ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1129		Lines:   []string{"old line 1", "old line 2", "new reply A", "new reply B"},
1130		Started: cur[0].Started,
1131	}}
1132	out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1133	for _, want := range []string{"old line 1", "old line 2", "new reply A", "new reply B"} {
1134		if !strings.Contains(out, want) {
1135			t.Errorf("line %q missing after growth:\n%s", want, out)
1136		}
1137	}
1138}
1139
1140// TestSupervisorTranscriptBtwStyleMultipleSends pins the /btw-style flow
1141// for the transcript view: after one Enter-submit the editor stays
1142// mounted and clears, and a second message can be composed and sent
1143// without any modal toggle. Regresses the previous design where
1144// every send closed the prompt and forced the user to press `p`
1145// again.
1146func TestSupervisorTranscriptBtwStyleMultipleSends(t *testing.T) {
1147	rows := []subagents.AgentSnapshot{{
1148		ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(),
1149		Lines: []string{"warm-up"},
1150	}}
1151	var sent []string
1152	d := newSubagentsDialog()
1153	d.Open(staticSnapshots(rows...), nil, nil, nil,
1154		func(_, text string) error { sent = append(sent, text); return nil },
1155		nil, "")
1156	_ = d.Render(tui.Theme{}, 80)
1157	d.HandleKey(tui.Key{Kind: tui.KeyEnter}) // open transcript + auto-editor
1158
1159	// First message.
1160	for _, r := range "first" {
1161		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1162	}
1163	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1164
1165	// Second message, no `p` toggle in between.
1166	for _, r := range "second" {
1167		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
1168	}
1169	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1170
1171	if len(sent) != 2 || sent[0] != "first" || sent[1] != "second" {
1172		t.Fatalf("sent = %v; want [first second]", sent)
1173	}
1174	if !d.viewing || !d.prompting {
1175		t.Errorf("transcript view/editor torn down between sends (viewing=%v prompting=%v)", d.viewing, d.prompting)
1176	}
1177}
1178
1179// TestSupervisorTranscriptEscClosesView pins the Esc semantics for the
1180// /btw-style transcript view: a single Esc closes the whole view
1181// (back to the dashboard list), regardless of whether the editor
1182// is currently composing. Without this routing the editor's own
1183// Esc handler would just clear the modal and leave the user stuck
1184// inside an empty transcript view.
1185func TestSupervisorTranscriptEscClosesView(t *testing.T) {
1186	rows := []subagents.AgentSnapshot{{
1187		ID: "agent-1", Task: "x", Status: subagents.StatusRunning, Started: time.Now(),
1188		Lines: []string{"hi"},
1189	}}
1190	d := newSubagentsDialog()
1191	d.Open(staticSnapshots(rows...), nil, nil, nil,
1192		func(string, string) error { return nil },
1193		nil, "")
1194	_ = d.Render(tui.Theme{}, 80)
1195	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1196	if !d.viewing || !d.prompting {
1197		t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1198	}
1199
1200	d.HandleKey(tui.Key{Kind: tui.KeyEsc})
1201	if d.viewing {
1202		t.Error("Esc did not close the transcript view")
1203	}
1204	if d.prompting {
1205		t.Error("Esc did not tear down the inline editor")
1206	}
1207}
1208
1209// TestSupervisorTranscriptNonRunningAgentNoEditor pins the safety net:
1210// transcripts for done / failed / killed agents must NOT auto-open
1211// an editor (the agent's inbox isn't listening, so a send would
1212// just bounce). The view shows the transcript plus a hint about
1213// resuming instead.
1214func TestSupervisorTranscriptNonRunningAgentNoEditor(t *testing.T) {
1215	rows := []subagents.AgentSnapshot{{
1216		ID: "agent-1", Task: "x", Status: subagents.StatusDone, Started: time.Now(),
1217		Lines: []string{"all done"},
1218	}}
1219	d := newSubagentsDialog()
1220	d.Open(staticSnapshots(rows...), nil, nil, nil,
1221		func(string, string) error { return nil },
1222		nil, "")
1223	_ = d.Render(tui.Theme{}, 80)
1224	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1225	if d.prompting {
1226		t.Fatal("editor auto-opened for a done agent; should only happen for running ones")
1227	}
1228	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1229	if !strings.Contains(out, "prompt: agent is done") {
1230		t.Errorf("hint about non-running status missing:\n%s", out)
1231	}
1232}
1233
1234// TestSupervisorTranscriptTickRefreshWhileEditing pins the streaming
1235// fix: with the always-on editor in transcript view, the host's
1236// animation tick MUST still call us so streaming agent output
1237// renders. Without this, sending a message and watching the reply
1238// land silently failed because the prompting=true branch was
1239// suppressing redraws (like the modal editors do).
1240func TestSupervisorTranscriptTickRefreshWhileEditing(t *testing.T) {
1241	rows := []subagents.AgentSnapshot{{
1242		ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1243		Activity: "thinking", Started: time.Now(),
1244		Lines: []string{"hello"},
1245	}}
1246	d := newSubagentsDialog()
1247	d.Open(staticSnapshots(rows...), nil, nil, nil,
1248		func(string, string) error { return nil },
1249		nil, "")
1250	_ = d.Render(tui.Theme{}, 80)
1251	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1252	if !d.viewing || !d.prompting {
1253		t.Fatalf("setup: viewing=%v prompting=%v", d.viewing, d.prompting)
1254	}
1255
1256	if !d.NeedsTickRefresh() {
1257		t.Fatal("transcript view with always-on editor refused tick refresh; streaming output would freeze")
1258	}
1259}
1260
1261// TestSupervisorTranscriptTickRefreshModalSuppressed pins the inverse:
1262// the list-view `p` modal (prompting without viewing) still
1263// suppresses tick like the other modal editors, so the cursor
1264// blink works cleanly while composing a one-off prompt.
1265func TestSupervisorTranscriptTickRefreshModalSuppressed(t *testing.T) {
1266	rows := []subagents.AgentSnapshot{{
1267		ID: "agent-1", Status: subagents.StatusRunning, Started: time.Now(),
1268	}}
1269	d := newSubagentsDialog()
1270	d.Open(staticSnapshots(rows...), nil, nil, nil,
1271		func(string, string) error { return nil },
1272		nil, "")
1273	_ = d.Render(tui.Theme{}, 80)
1274	d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'p'})
1275	if d.viewing || !d.prompting {
1276		t.Fatalf("setup: viewing=%v prompting=%v; want list-view modal", d.viewing, d.prompting)
1277	}
1278
1279	if d.NeedsTickRefresh() {
1280		t.Error("list-view prompt modal did not suppress tick refresh; cursor blink may misbehave")
1281	}
1282}
1283
1284// TestSupervisorTranscriptBusySpinnerRenders proves the inline spinner
1285// appears above the always-on editor while the agent is busy and
1286// disappears when it goes idle. This is the /btw-style "something
1287// is happening" feedback the user was missing.
1288func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1289	cur := []subagents.AgentSnapshot{{
1290		ID: "agent-1", Task: "x", Status: subagents.StatusRunning,
1291		Activity: "thinking", Started: time.Now(),
1292		Lines: []string{"hello"},
1293	}}
1294	snap := func() []subagents.AgentSnapshot { return cur }
1295	d := newSubagentsDialog()
1296	d.Open(snap, nil, nil, nil,
1297		func(string, string) error { return nil },
1298		nil, "")
1299	_ = d.Render(tui.Theme{}, 80)
1300	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1301
1302	out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1303	if !strings.Contains(out, "thinking") {
1304		t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out)
1305	}
1306	if d.transcriptSpin == nil {
1307		t.Error("spinner not initialised while agent is busy")
1308	}
1309
1310	// Flip the activity to idle: spinner should vanish.
1311	cur[0].Activity = "idle"
1312	out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1313	if d.transcriptSpin != nil {
1314		t.Error("spinner stayed live after agent reported idle")
1315	}
1316	// The bare word "thinking" must not appear anymore.
1317	if strings.Contains(out, "· thinking, ") {
1318		t.Errorf("busy line still rendered when agent is idle:\n%s", out)
1319	}
1320}
1321
1322func TestStatusLabelCoversAll(t *testing.T) {
1323	for _, s := range []subagents.Status{
1324		subagents.StatusPending, subagents.StatusRunning,
1325		subagents.StatusDone, subagents.StatusFailed, subagents.StatusKilled,
1326	} {
1327		if statusLabel(s) == "" {
1328			t.Fatalf("no label for %s", s)
1329		}
1330	}
1331}

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 476

↑↓ moveenter openesc close