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