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}