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