1package modes
2
3import (
4 "errors"
5 "fmt"
6 "strings"
7 "time"
8
9 "github.com/patriceckhart/zot/internal/swarm"
10 "github.com/patriceckhart/zot/internal/tui"
11)
12
13// swarmDialog is the dashboard shown by /swarm (no argument) and
14// /swarm list. It's a read-only window onto a *swarm.Swarm; the
15// caller passes a snapshotter so the dialog never holds the swarm
16// pointer or its lock.
17//
18// Keys (list view):
19//
20// n spawn a new agent (inline task prompt)
21// p send a follow-up prompt to the selected agent
22// R resume (restart) a detached / finished agent
23// ↑/↓ move cursor
24// enter show transcript tail for the selected agent
25// k kill (Stop) the selected running agent
26// r remove a terminated agent (deletes worktree)
27// esc / q close
28//
29// Keys (transcript view):
30//
31// p send a follow-up prompt to this agent (inline editor)
32// ↑/↓/PgUp/PgDn/Home/End scroll
33// esc / q return to the list
34type swarmDialog struct {
35 active bool
36 snapshot func() []swarm.AgentSnapshot
37 stop func(id string) error
38 remove func(id string) error
39 // spawn accepts an optional model + provider override (empty
40 // strings mean "let the child resolve its own default"). The cli
41 // adapter forwards these to swarm.Swarm.SpawnReq.
42 spawn func(task, model, provider string) error
43 // send delivers a follow-up user turn to a running agent's inbox.
44 // Wired by Open(); when nil the inline 'p' shortcut is disabled.
45 send func(id, text string) error
46 // resume restarts a detached or terminated agent on its existing
47 // worktree/session. Wired by Open(); when nil the inline 'R'
48 // shortcut is disabled.
49 resume func(id string) error
50
51 rows []swarm.AgentSnapshot
52 cursor int
53 viewing bool // when true, show full transcript of the selected row
54
55 // transcriptSpin animates the busy hint shown above the inline
56 // editor while the agent is mid-turn (status running, activity
57 // anything other than idle). Same shape as the /btw spinner and
58 // the main chat's busy line, so the user gets a consistent
59 // "something's happening" signal. Lazily created on first
60 // busy frame; reset by Open / Close.
61 transcriptSpin *spinner
62
63 // spawning toggles the inline "enter task" editor. While true the
64 // dialog captures keys into a real tui.Editor so swarm-new has the
65 // same editing, paste, drag-drop file chips, and visual style as
66 // the default input and /btw input.
67 spawning bool
68 newTaskEd *tui.Editor
69 fileSuggest *fileSuggester
70 cwd string
71
72 // pendingModel / pendingProvider are the model + provider every
73 // freshly-spawned agent will be pinned to. They're seeded from
74 // the host's currently-active model (via SetCurrentModel) at the
75 // moment the user opens the dashboard, so swarm agents inherit
76 // whatever the user last picked via /model. Empty means "don't
77 // pin a model; let the child resolve its own default".
78 pendingModel string
79 pendingProvider string
80
81 // pickingModel + modelPicker drive the in-spawn-editor /model
82 // command: while typing a task into the spawn editor, the user
83 // can submit just "/model" to pop the same picker /model uses
84 // globally, choose a different model for this spawn, and resume
85 // the editor with the new pending model captured. Empty when
86 // the picker isn't on screen.
87 pickingModel bool
88 modelPicker *modelDialog
89 modelPickerLoggedIn []string
90
91 // spawnDraft remembers the editor buffer when we suspend it to
92 // show the model picker, so the user doesn't lose half-typed
93 // task text just because they wanted to switch models. Restored
94 // when the picker closes (whether by select or Esc).
95 spawnDraft string
96
97 // prompting toggles the inline "send prompt to selected agent"
98 // editor. Distinct from `spawning` so we don't accidentally
99 // route a follow-up into Swarm.Spawn. The editor and its
100 // file-suggest live in their own fields so opening the prompt
101 // editor while a spawn editor is half-typed doesn't clobber it
102 // (the dialog enforces one-at-a-time, but the separate state
103 // keeps the lifecycle obvious).
104 prompting bool
105 promptEd *tui.Editor
106 promptSuggest *fileSuggester
107 // promptTargetID is the agent id captured the moment the user
108 // pressed 'p'. We pin it here so the prompt routes to the same
109 // agent even if the user's cursor moves (or rows reorder) while
110 // they're typing.
111 promptTargetID string
112}
113
114func newSwarmDialog() *swarmDialog { return &swarmDialog{} }
115
116// SetCurrentModel pins the model + provider every fresh spawn will
117// inherit. The host wires this to the same Model / Provider the rest
118// of zot is currently using, so agents started from the dashboard
119// run on whatever the user last picked via /model. Pass empty
120// strings to clear the override (the child then resolves its own
121// default the same way a bare `zot` invocation does).
122func (d *swarmDialog) SetCurrentModel(model, providerID string) {
123 d.pendingModel = model
124 d.pendingProvider = providerID
125}
126
127// SetLoggedInProviders supplies the list of providers the user has
128// active credentials for. Forwarded to the embedded modelDialog when
129// the user invokes /model inside the spawn editor so the picker can
130// filter out unauthenticated providers, matching the global /model
131// behaviour.
132func (d *swarmDialog) SetLoggedInProviders(provs []string) {
133 d.modelPickerLoggedIn = provs
134}
135
136// Open binds the dialog to a swarm via the provided callbacks. The
137// dialog refreshes the snapshot every Render so the dashboard reads
138// live state without needing a goroutine. spawn / send are optional;
139// when nil the corresponding inline shortcut ('n' / 'p') is disabled.
140func (d *swarmDialog) Open(
141 snapshot func() []swarm.AgentSnapshot,
142 stop func(id string) error,
143 remove func(id string) error,
144 spawn func(task, model, provider string) error,
145 send func(id, text string) error,
146 resume func(id string) error,
147 cwd string,
148) {
149 d.snapshot = snapshot
150 d.stop = stop
151 d.remove = remove
152 d.spawn = spawn
153 d.send = send
154 d.resume = resume
155 d.active = true
156 d.cursor = 0
157 d.viewing = false
158 d.spawning = false
159 d.newTaskEd = nil
160 d.fileSuggest = nil
161 d.prompting = false
162 d.promptEd = nil
163 d.promptSuggest = nil
164 d.promptTargetID = ""
165 // pendingModel / pendingProvider are NOT cleared here: the host
166 // sets them via SetCurrentModel just before opening the dialog
167 // so the spawn flow inherits whatever /model selected. Resetting
168 // here would undo that.
169 d.pickingModel = false
170 d.modelPicker = nil
171 d.spawnDraft = ""
172 d.transcriptSpin = nil
173 d.cwd = cwd
174 d.refresh()
175}
176
177// CursorPos returns the row/col for the terminal cursor while an
178// inline editor (spawn or prompt) is active so the caret blinks in
179// the right spot. Returns -1, -1 when no input is being captured.
180func (d *swarmDialog) CursorPos(width int) (row, col int) {
181 if !d.Active() {
182 return -1, -1
183 }
184 // Spawn editor (list view): rendered directly under the frame
185 // header, optionally preceded by the model banner row (when a
186 // model was picked) and the @ file-suggest popup, with one
187 // breathing-room blank row inserted just above the editor. The
188 // constants below must mirror the spawn-editor render path.
189 if d.spawning && d.newTaskEd != nil {
190 modelBannerRows := 0
191 if d.pendingModel != "" {
192 modelBannerRows = 1
193 }
194 popupRows := 0
195 if d.fileSuggest != nil && d.fileSuggest.Active(d.newTaskEd.Value()) {
196 popupRows = len(d.fileSuggest.Render(d.newTaskEd.Value(), tui.Theme{}, width))
197 }
198 _, eRow, eCol := d.newTaskEd.Render(width - 2)
199 const editorBlankRow = 1
200 return 1 + modelBannerRows + popupRows + editorBlankRow + eRow, eCol + 2
201 }
202 // Prompt editor cursor placement. Two flavours:
203 // 1. List-view modal (`p` from the dashboard list): renderPromptEditor
204 // draws frame header + "send to <id>:" hint + popup + editor.
205 // 2. Transcript view (always-on editor, /btw-style): the editor
206 // lives at the bottom of renderTranscript's output, after the
207 // metadata block, the "── transcript ──" rule, the transcript
208 // body itself, optional "↑/↓ N more" rows and the @-popup.
209 //
210 // Both flavours funnel through here. The math must mirror the
211 // corresponding render path exactly.
212 if d.prompting && d.promptEd != nil {
213 popupRows := 0
214 if d.promptSuggest != nil && d.promptSuggest.Active(d.promptEd.Value()) {
215 popupRows = len(d.promptSuggest.Render(d.promptEd.Value(), tui.Theme{}, width))
216 }
217 _, eRow, eCol := d.promptEd.Render(width - 2)
218
219 if d.viewing {
220 return d.transcriptEditorCursorRow(width, popupRows, eRow), eCol + 2
221 }
222
223 // List-view modal layout: frame header + "send to <id>:" hint
224 // + popup + blank + editor.
225 const headerRows = 1
226 const sendToHintRow = 1
227 const editorBlankRow = 1
228 return headerRows + sendToHintRow + popupRows + editorBlankRow + eRow, eCol + 2
229 }
230 return -1, -1
231}
232
233// transcriptEditorCursorRow computes the visual row the caret
234// belongs on when the always-on editor sits at the bottom of the
235// transcript view. Mirrors renderTranscript's row layout so the
236// terminal cursor stays glued to the editor as the body grows.
237func (d *swarmDialog) transcriptEditorCursorRow(width, popupRows, editorRowOffset int) int {
238 a := d.selected()
239 if a == nil {
240 return -1
241 }
242 row := 1 // frame header
243 row += 4 // task / branch / dir / status
244 if a.Model != "" {
245 row++
246 }
247 if a.Err != "" {
248 row++
249 }
250 row += 3 // blank + "── transcript ──" + trailing blank
251
252 // Replay transcript body sizing (no-transcript-yet path vs.
253 // real body path) so the editor row count matches what render
254 // actually emitted.
255 raw := a.Lines
256 if len(raw) == 0 && a.Tail != "" {
257 raw = strings.Split(a.Tail, "\n")
258 }
259 if len(raw) == 0 {
260 row++ // "(no transcript yet)" line
261 } else {
262 // Full body, no scroll window — the host's outer scrollback
263 // owns overflow now. Cursor row math just counts every
264 // rendered line.
265 body := renderSwarmTranscriptBlocks(raw, tui.Theme{}, width)
266 row += len(body)
267 }
268
269 row += popupRows // @-file-suggest popup, if active
270 if agentIsBusy(a) {
271 row += 2 // blank + spinner row inserted above the editor
272 }
273 row++ // blank above editor (appendTranscriptEditor)
274 return row + editorRowOffset
275}
276
277func (d *swarmDialog) Active() bool { return d != nil && d.active }
278func (d *swarmDialog) Close() { d.active = false }
279
280// NeedsTickRefresh reports whether the host's animation tick should
281// repaint the screen while this dialog is open. True when the
282// dashboard's list/transcript view is on (rows / activity / age and
283// the transcript itself are background-driven and need a periodic
284// redraw — essential for streaming agent replies to actually appear
285// on screen). False only while a modal editor (spawn, list-view
286// prompt, model picker) has the dialog to itself; those need the
287// terminal's cursor blink, which a full-frame redraw cancels.
288//
289// Note: the transcript view's always-on editor does NOT suppress
290// tick refresh. The cursor blink still works because the tick rate
291// is well below the terminal's blink rate, and we need the redraws
292// for streaming output. /btw makes the same trade-off.
293func (d *swarmDialog) NeedsTickRefresh() bool {
294 if d == nil || !d.active {
295 return false
296 }
297 if d.spawning || d.pickingModel {
298 return false
299 }
300 // The list-view `p` modal (prompting without viewing) suppresses
301 // tick like the other modal editors. The transcript-view
302 // always-on editor (viewing && prompting) does not — streaming
303 // agent output needs periodic redraws to render.
304 if d.prompting && !d.viewing {
305 return false
306 }
307 return true
308}
309
310// OpenViewing opens the dashboard and jumps straight to the
311// transcript view for the agent with id agentID. Used by /swarm logs
312// <id>. If no such agent is found the dashboard opens normally so
313// the user can see why (and pick from the list).
314func (d *swarmDialog) OpenViewing(
315 agentID string,
316 snapshot func() []swarm.AgentSnapshot,
317 stop func(id string) error,
318 remove func(id string) error,
319 spawn func(task, model, provider string) error,
320 send func(id, text string) error,
321 resume func(id string) error,
322 cwd string,
323) bool {
324 d.Open(snapshot, stop, remove, spawn, send, resume, cwd)
325 rows := d.snapshot()
326 for i, r := range rows {
327 if r.ID == agentID || strings.HasPrefix(r.ID, agentID) {
328 d.cursor = i
329 d.viewing = true
330 // Auto-open the inline editor for running agents so
331 // /swarm logs <id> drops the user straight into a
332 // /btw-style send-ready prompt.
333 if r.Status == swarm.StatusRunning && d.send != nil {
334 d.openPromptEditor(tui.Theme{}, r.ID)
335 }
336 return true
337 }
338 }
339 return false
340}
341
342// OpenForResume opens the dashboard and parks the cursor on the first
343// resumable agent (any non-running status), so the user can press R
344// to resume without typing an id. Returns the count of resumable
345// agents found — callers surface that as "3 resumable, press R" or,
346// when zero, "no agents available to resume".
347//
348// The dialog still shows the full list (live + detached + terminated)
349// so the user has context: pressing ↑/↓ they can still walk through
350// running agents, and the R key is a no-op there (Resume rejects
351// non-terminal agents with a clear error).
352func (d *swarmDialog) OpenForResume(
353 snapshot func() []swarm.AgentSnapshot,
354 stop func(id string) error,
355 remove func(id string) error,
356 spawn func(task, model, provider string) error,
357 send func(id, text string) error,
358 resume func(id string) error,
359 cwd string,
360) (resumable int) {
361 d.Open(snapshot, stop, remove, spawn, send, resume, cwd)
362 // d.rows was populated by Open's refresh(); walk it for the
363 // first resumable row and count the total in one pass.
364 for i, r := range d.rows {
365 if isResumable(r.Status) {
366 if resumable == 0 {
367 d.cursor = i
368 }
369 resumable++
370 }
371 }
372 return resumable
373}
374
375// isResumable reports whether a Status is eligible for Resume. Mirror
376// of the precondition inside Swarm.Resume; centralised so the dialog
377// and the slash handler agree on what "resumable" means.
378func isResumable(s swarm.Status) bool {
379 switch s {
380 case swarm.StatusDetached,
381 swarm.StatusDone,
382 swarm.StatusFailed,
383 swarm.StatusKilled:
384 return true
385 }
386 return false
387}
388
389// promptDisabledHint returns the status-bar message shown when the
390// user presses 'p' on an agent that can't receive input. We branch on
391// status so the hint points at the right next step: resume for the
392// states Swarm.Resume accepts; nothing useful otherwise.
393func promptDisabledHint(s swarm.Status) string {
394 if isResumable(s) {
395 return "prompt: agent is " + string(s) + "; press R to resume first"
396 }
397 return "prompt: agent is " + string(s) + " and can't receive input"
398}
399
400// killDisabledHint mirrors promptDisabledHint for the 'k' shortcut.
401// Kill only makes sense on running / pending agents; on detached and
402// terminal ones it's a no-op and the user usually wants 'r' (remove)
403// to clean up the worktree instead.
404func killDisabledHint(s swarm.Status) string {
405 return "kill: agent is " + string(s) + "; nothing to stop (press r to remove)"
406}
407
408// friendlySendErr turns a raw send error into a status-bar message
409// the user can act on. The most common failure mode — dialling an
410// agent whose previous subprocess exited — surfaces as
411// swarm.ErrNotReady; we rewrite it to mention resume instead of
412// leaking the unix-socket path. Other errors fall through verbatim
413// because they're rare and almost always indicate a real bug.
414func friendlySendErr(id string, err error) string {
415 if errors.Is(err, swarm.ErrNotReady) {
416 return "send: agent " + id + " isn't accepting input (press R to resume)"
417 }
418 return "send: " + err.Error()
419}
420
421func (d *swarmDialog) refresh() {
422 if d.snapshot == nil {
423 d.rows = nil
424 return
425 }
426 d.rows = d.snapshot()
427 if d.cursor >= len(d.rows) {
428 d.cursor = len(d.rows) - 1
429 }
430 if d.cursor < 0 {
431 d.cursor = 0
432 }
433}
434
435// HandleKey advances state. Returns (closed, statusMsg, statusErr).
436// statusMsg / statusErr surface in the host's status bar so the user
437// gets feedback on kill/remove without writing to the chat.
438func (d *swarmDialog) HandleKey(k tui.Key) (closed bool, msg, errMsg string) {
439 if !d.Active() {
440 return false, "", ""
441 }
442 d.refresh()
443
444 // Inline editors own key handling. They run before the other
445 // modes so the user's typed letters aren't interpreted as 'k'
446 // (kill), 'p' (prompt), etc.
447 //
448 // Transcript view is a special case: the editor is always on,
449 // but we still need transcript-level keys (PageUp/PageDown/
450 // Home/End scroll, Esc close) to take precedence over the
451 // editor. handleViewingKey owns that routing and delegates
452 // editor input to handlePromptKey itself.
453 if d.viewing {
454 return d.handleViewingKey(k)
455 }
456 if d.prompting {
457 return d.handlePromptKey(k)
458 }
459 if d.pickingModel {
460 return d.handleModelPickerKey(k)
461 }
462 if d.spawning {
463 return d.handleSpawnKey(k)
464 }
465
466 switch k.Kind {
467 case tui.KeyEsc:
468 d.Close()
469 return true, "", ""
470 case tui.KeyUp:
471 if d.cursor > 0 {
472 d.cursor--
473 }
474 case tui.KeyDown:
475 if d.cursor < len(d.rows)-1 {
476 d.cursor++
477 }
478 case tui.KeyEnter:
479 if len(d.rows) > 0 {
480 d.viewing = true
481 // Auto-open the inline editor (à la /btw) so the user
482 // can start typing immediately. Skipped for non-running
483 // agents; their inbox isn't listening, so an editor
484 // would just frustrate them with bounced sends.
485 if a := d.selected(); a != nil && a.Status == swarm.StatusRunning && d.send != nil {
486 d.openPromptEditor(tui.Theme{}, a.ID)
487 }
488 }
489 case tui.KeyRune:
490 switch k.Rune {
491 case 'q':
492 d.Close()
493 return true, "", ""
494 case 'n':
495 if d.spawn != nil {
496 // Open the task editor directly. The model the new
497 // agent will run against was already captured via
498 // SetCurrentModel from the host's /model selection;
499 // the user changes models the normal way (/model)
500 // before opening /swarm.
501 d.openSpawnEditor(tui.Theme{})
502 }
503 case 'p':
504 if a := d.selected(); a != nil && d.send != nil {
505 // Only running agents have a live inbox listener;
506 // dialling the unix socket on a detached / done /
507 // failed / killed agent fails with a long ECONNREFUSED
508 // or ENOENT trail that's not actionable. Tell the user
509 // what to do instead.
510 if a.Status != swarm.StatusRunning {
511 return false, "", promptDisabledHint(a.Status)
512 }
513 d.openPromptEditor(tui.Theme{}, a.ID)
514 }
515 case 'R':
516 // Capital R for resume; lowercase r is already taken by
517 // remove. Using a different keystroke avoids a confirm
518 // modal for an action that's already destructive (resume
519 // is reversible, so we don't need the shift-as-guard
520 // dance lowercase r has historically warranted).
521 if a := d.selected(); a != nil && d.resume != nil {
522 if err := d.resume(a.ID); err != nil {
523 return false, "", "resume: " + err.Error()
524 }
525 return false, "resumed " + a.ID, ""
526 }
527 case 'k':
528 if a := d.selected(); a != nil && d.stop != nil {
529 // Only running / pending agents have something to
530 // cancel. Detached and already-terminal agents would
531 // be silent no-ops at the Swarm layer; surface a hint
532 // so the user knows kill did nothing and what to do
533 // instead (remove via 'r').
534 if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {
535 return false, "", killDisabledHint(a.Status)
536 }
537 if err := d.stop(a.ID); err != nil {
538 return false, "", "stop: " + err.Error()
539 }
540 return false, "stopped " + a.ID, ""
541 }
542 case 'r':
543 if a := d.selected(); a != nil && d.remove != nil {
544 if err := d.remove(a.ID); err != nil {
545 return false, "", "remove: " + err.Error()
546 }
547 return false, "removed " + a.ID, ""
548 }
549 }
550 }
551 return false, "", ""
552}
553
554// handleViewingKey routes one keystroke while the transcript view
555// is on screen. The transcript view runs /btw-style: an inline
556// editor is always pinned at the bottom, the transcript flows in
557// full above it, and the host's outer scrollback handles overflow.
558// There is no internal scroll, so this handler is small:
559//
560// - Esc closes the transcript view (back to the dashboard list).
561// If the user was in the middle of an @-file-pick we let the
562// editor handler eat the Esc first so it just cancels the popup.
563//
564// Every other key is forwarded to handlePromptKey, which owns the
565// editor itself (typed runes, Enter to submit, Backspace, file
566// suggest, arrow keys for caret movement, etc.). Submits stay in
567// the view and clear the editor for the next message instead of
568// closing it — see handlePromptKey for the always-on behaviour.
569func (d *swarmDialog) handleViewingKey(k tui.Key) (closed bool, msg, errMsg string) {
570 a := d.selected()
571
572 // Lazy-open the editor on first key into the transcript view, so
573 // re-entering an agent after switching away picks up cleanly.
574 // Skipped for non-running agents (their inbox isn't listening;
575 // sending would just bounce).
576 if a != nil && a.Status == swarm.StatusRunning && d.send != nil && !d.prompting {
577 d.openPromptEditor(tui.Theme{}, a.ID)
578 }
579
580 // Esc closes the transcript view, regardless of whether the
581 // editor is open. Handled here (not in the editor handler) so
582 // the user gets a consistent "esc backs out one level" feel.
583 // The file-suggest popup, if active, gets to eat the Esc first
584 // inside handlePromptKey — we route there and check whether the
585 // editor consumed it.
586 if k.Kind == tui.KeyEsc {
587 if d.prompting && d.promptSuggest != nil && d.promptEd != nil && d.promptSuggest.Active(d.promptEd.Value()) {
588 // Let the editor's @-suggest popup handle the Esc to
589 // dismiss itself instead of collapsing the whole view.
590 return d.handlePromptKey(k)
591 }
592 d.viewing = false
593 d.closePromptEditor()
594 return false, "", ""
595 }
596
597 // Everything else — typed runes, Enter, Backspace, arrow keys
598 // for caret movement, @-file picker — is editor input. If the
599 // agent isn't running there's no live editor; surface a hint.
600 if a == nil || a.Status != swarm.StatusRunning || d.send == nil {
601 if k.Kind == tui.KeyRune && a != nil {
602 return false, "", promptDisabledHint(a.Status)
603 }
604 return false, "", ""
605 }
606 return d.handlePromptKey(k)
607}
608
609// openSpawnEditor enters the inline new-agent prompt. It deliberately
610// uses tui.Editor (not a one-line string) so swarm-new supports the same
611// editing model as the normal input: multiline paste collapse, drag-drop
612// file/dir chips, cursor movement, and the same accent-bar prompt style.
613func (d *swarmDialog) openSpawnEditor(th tui.Theme) {
614 d.spawning = true
615 d.newTaskEd = tui.NewEditor(th.AccentBar(th.Accent))
616 d.fileSuggest = newFileSuggester()
617 d.fileSuggest.SetCWD(d.cwd)
618 if d.spawnDraft != "" {
619 d.newTaskEd.SetValue(d.spawnDraft)
620 }
621}
622
623// openModelPicker suspends the spawn editor and shows the same
624// model dialog the global /model uses. Triggered when the user
625// submits "/model" from inside the spawn editor; on select we
626// capture the new pendingModel/Provider and reopen the spawn
627// editor with the previously-typed task buffer restored.
628func (d *swarmDialog) openModelPicker() {
629 d.spawning = false
630 d.newTaskEd = nil
631 d.fileSuggest = nil
632 d.pickingModel = true
633 d.modelPicker = newModelDialog()
634 d.modelPicker.Open(d.pendingModel, d.modelPickerLoggedIn)
635}
636
637// handleModelPickerKey forwards one keystroke to the embedded model
638// picker and, on select or close, reopens the spawn editor so the
639// user can continue composing the task. A selection captures the new
640// pendingModel + pendingProvider; Esc leaves them unchanged (the
641// user gets to keep whatever was inherited before they invoked
642// /model).
643func (d *swarmDialog) handleModelPickerKey(k tui.Key) (closed bool, msg, errMsg string) {
644 if d.modelPicker == nil {
645 d.openModelPicker()
646 }
647 act := d.modelPicker.HandleKey(k)
648 switch {
649 case act.Select:
650 d.pendingModel = act.Model
651 d.pendingProvider = act.Provider
652 d.pickingModel = false
653 d.modelPicker = nil
654 d.openSpawnEditor(tui.Theme{})
655 return false, "model: " + act.Model, ""
656 case act.Close:
657 d.pickingModel = false
658 d.modelPicker = nil
659 d.openSpawnEditor(tui.Theme{})
660 return false, "", ""
661 }
662 return false, "", ""
663}
664
665// containsSlashModelLine reports whether any line in buf has trimmed
666// content of "/model" (with optional trailing args). Used to decide
667// whether to treat a spawn-editor submit as a model-picker command
668// vs. as a task description.
669func containsSlashModelLine(buf string) bool {
670 for _, ln := range strings.Split(buf, "\n") {
671 t := strings.TrimSpace(ln)
672 if t == "/model" || strings.HasPrefix(t, "/model ") {
673 return true
674 }
675 }
676 return false
677}
678
679// stripSlashModelLine removes the line containing "/model" (with
680// optional trailing args) from the spawn-editor buffer so the
681// remainder can be restored after the picker closes. The /model line
682// is always exactly one full line in practice — the user typed
683// /model<Enter> — but we tolerate inline placement at the start of
684// a line just in case.
685func stripSlashModelLine(buf string) string {
686 lines := strings.Split(buf, "\n")
687 out := lines[:0]
688 for _, ln := range lines {
689 t := strings.TrimSpace(ln)
690 if t == "/model" || strings.HasPrefix(t, "/model ") {
691 continue
692 }
693 out = append(out, ln)
694 }
695 return strings.TrimRight(strings.Join(out, "\n"), "\n")
696}
697
698// openPromptEditor enters the inline "send a follow-up prompt" editor
699// for the agent with id targetID. Mirrors openSpawnEditor so the
700// editing experience (paste, @-picker, drop chips) is identical.
701func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
702 d.prompting = true
703 d.promptEd = tui.NewEditor(th.AccentBar(th.Accent))
704 d.promptSuggest = newFileSuggester()
705 d.promptSuggest.SetCWD(d.cwd)
706 d.promptTargetID = targetID
707}
708
709// handlePromptKey owns the keystrokes while the inline prompt editor
710// is open. It mirrors handleSpawnKey but, on submit, invokes d.send
711// with the captured target id instead of d.spawn. Esc cancels and
712// returns the user to whichever view (list or transcript) was active
713// when 'p' was pressed.
714func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) {
715 if d.promptEd == nil {
716 d.openPromptEditor(tui.Theme{}, d.promptTargetID)
717 }
718 ed := d.promptEd
719 fs := d.promptSuggest
720 if fs != nil {
721 fs.SetCWD(d.cwd)
722 }
723
724 // File suggestions: same keys and chip shape as the default input.
725 if fs != nil && fs.Active(ed.Value()) {
726 switch k.Kind {
727 case tui.KeyUp:
728 fs.Up()
729 return false, "", ""
730 case tui.KeyDown:
731 fs.Down()
732 return false, "", ""
733 case tui.KeyRight:
734 fs.Right()
735 return false, "", ""
736 case tui.KeyLeft:
737 fs.Left()
738 return false, "", ""
739 case tui.KeyEnter:
740 if entry, ok := fs.SelectedEntry(ed.Value()); ok {
741 chip := "[file:" + entry.rel + "]"
742 if entry.isDir {
743 chip = "[dir:" + entry.rel + "/]"
744 }
745 val := ed.Value()
746 if idx := strings.LastIndex(val, "@"); idx >= 0 {
747 val = val[:idx]
748 }
749 ed.SetValue(val + chip + " ")
750 fs.Reset()
751 }
752 return false, "", ""
753 case tui.KeyEsc:
754 val := ed.Value()
755 if idx := strings.LastIndex(val, "@"); idx >= 0 {
756 ed.SetValue(val[:idx])
757 }
758 fs.Reset()
759 return false, "", ""
760 }
761 }
762
763 // Esc cancels the prompt editor when the file picker isn't active.
764 if k.Kind == tui.KeyEsc {
765 d.closePromptEditor()
766 return false, "", ""
767 }
768
769 if submitted := ed.HandleKey(k); submitted {
770 text := strings.TrimRight(ed.SubmitValue(), "\n")
771 text = expandFileChips(text, d.cwd)
772 text = strings.TrimSpace(text)
773 targetID := d.promptTargetID
774
775 // Empty submit: nothing to send. If we're in transcript
776 // view (always-on editor), just clear and stay; otherwise
777 // (list-view-p flow) close the modal.
778 if text == "" {
779 if d.viewing {
780 ed.Clear()
781 return false, "", ""
782 }
783 d.closePromptEditor()
784 return false, "", ""
785 }
786 if d.send == nil {
787 if !d.viewing {
788 d.closePromptEditor()
789 }
790 return false, "", "send not wired"
791 }
792 if targetID == "" {
793 if !d.viewing {
794 d.closePromptEditor()
795 }
796 return false, "", "send: no target agent"
797 }
798 if err := d.send(targetID, text); err != nil {
799 if !d.viewing {
800 d.closePromptEditor()
801 }
802 return false, "", friendlySendErr(targetID, err)
803 }
804
805 // In transcript view (always-on editor) keep the editor
806 // mounted and clear the buffer for the next message; in the
807 // list-view modal flow, close it so the user returns to the
808 // list. Either way, snap the transcript to the tail so the
809 // user sees their just-sent message and the agent's reply.
810 if d.viewing {
811 ed.Clear()
812 } else {
813 d.closePromptEditor()
814 }
815 return false, "sent to " + targetID, ""
816 }
817 return false, "", ""
818}
819
820// closePromptEditor resets all prompt-editor state. Called on Esc and
821// after submit so a subsequent 'p' starts from a clean slate.
822func (d *swarmDialog) closePromptEditor() {
823 d.prompting = false
824 d.promptEd = nil
825 d.promptSuggest = nil
826 d.promptTargetID = ""
827}
828
829// handleSpawnKey owns the keystrokes while the inline new-agent editor
830// is open. It mirrors the normal input's @ file-picker path and submit
831// expansion, then asks the host to spawn.
832func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) {
833 if d.newTaskEd == nil {
834 d.openSpawnEditor(tui.Theme{})
835 }
836 ed := d.newTaskEd
837 fs := d.fileSuggest
838 if fs != nil {
839 fs.SetCWD(d.cwd)
840 }
841
842 // File suggestions: same keys and chip shape as the default input.
843 if fs != nil && fs.Active(ed.Value()) {
844 switch k.Kind {
845 case tui.KeyUp:
846 fs.Up()
847 return false, "", ""
848 case tui.KeyDown:
849 fs.Down()
850 return false, "", ""
851 case tui.KeyRight:
852 fs.Right()
853 return false, "", ""
854 case tui.KeyLeft:
855 fs.Left()
856 return false, "", ""
857 case tui.KeyEnter:
858 if entry, ok := fs.SelectedEntry(ed.Value()); ok {
859 chip := "[file:" + entry.rel + "]"
860 if entry.isDir {
861 chip = "[dir:" + entry.rel + "/]"
862 }
863 val := ed.Value()
864 if idx := strings.LastIndex(val, "@"); idx >= 0 {
865 val = val[:idx]
866 }
867 ed.SetValue(val + chip + " ")
868 fs.Reset()
869 }
870 return false, "", ""
871 case tui.KeyEsc:
872 val := ed.Value()
873 if idx := strings.LastIndex(val, "@"); idx >= 0 {
874 ed.SetValue(val[:idx])
875 }
876 fs.Reset()
877 return false, "", ""
878 }
879 }
880
881 // Esc cancels the spawn prompt when the file picker isn't active.
882 if k.Kind == tui.KeyEsc {
883 d.spawning = false
884 d.newTaskEd = nil
885 d.fileSuggest = nil
886 return false, "", ""
887 }
888
889 if submitted := ed.HandleKey(k); submitted {
890 raw := strings.TrimRight(ed.SubmitValue(), "\n")
891
892 // In-editor /model command: when the buffer contains a line
893 // whose trimmed content is "/model" (anywhere — typically on
894 // its own line, but a trailing /model after a task works
895 // too), pop the same picker the global /model uses, drop the
896 // /model line, and preserve the rest of the buffer so the
897 // user can keep composing once they've picked.
898 if containsSlashModelLine(raw) {
899 d.spawnDraft = stripSlashModelLine(raw)
900 d.openModelPicker()
901 return false, "", ""
902 }
903
904 task := expandFileChips(raw, d.cwd)
905 task = strings.TrimSpace(task)
906 model := d.pendingModel
907 provider := d.pendingProvider
908 d.spawning = false
909 d.newTaskEd = nil
910 d.fileSuggest = nil
911 d.spawnDraft = ""
912 d.pendingModel = ""
913 d.pendingProvider = ""
914 if task == "" {
915 return false, "", ""
916 }
917 if d.spawn == nil {
918 return false, "", "spawn not wired"
919 }
920 if err := d.spawn(task, model, provider); err != nil {
921 return false, "", "spawn: " + err.Error()
922 }
923 if model != "" {
924 return false, "spawned (model " + model + ")", ""
925 }
926 return false, "spawned", ""
927 }
928 return false, "", ""
929}
930
931func (d *swarmDialog) selected() *swarm.AgentSnapshot {
932 if d.cursor < 0 || d.cursor >= len(d.rows) {
933 return nil
934 }
935 r := d.rows[d.cursor]
936 return &r
937}
938
939// Render returns the dialog lines. Re-reads the snapshot each call so
940// the dashboard updates as agents make progress.
941func (d *swarmDialog) Render(th tui.Theme, width int) []string {
942 if !d.Active() {
943 return nil
944 }
945 d.refresh()
946
947 if d.viewing {
948 return d.renderTranscript(th, width)
949 }
950
951 out := []string{frameHeader(th, "swarm (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)}
952 if d.prompting {
953 return d.renderPromptEditor(th, width, out)
954 }
955 if d.pickingModel {
956 // modelDialog renders its own frame; replace ours so the
957 // picker takes the dashboard cleanly. Trailing hint reminds
958 // the user Esc returns to the spawn editor.
959 if d.modelPicker == nil {
960 d.openModelPicker()
961 }
962 out = d.modelPicker.Render(th, width)
963 out = append(out, " "+th.FG256(th.Muted, "select model for next spawn, esc to cancel"))
964 return out
965 }
966 if d.spawning {
967 if d.newTaskEd == nil {
968 d.openSpawnEditor(th)
969 }
970 if d.newTaskEd != nil {
971 d.newTaskEd.Prompt = th.AccentBar(th.Accent)
972 }
973 // One-line banner reminding the user which model the
974 // agent-to-be will run against. Skipped when no model was
975 // picked so the layout matches the historical look.
976 if d.pendingModel != "" {
977 label := "model: " + d.pendingModel
978 if d.pendingProvider != "" {
979 label += " (" + d.pendingProvider + ")"
980 }
981 out = append(out, " "+th.FG256(th.Accent, label))
982 }
983 if d.fileSuggest != nil {
984 d.fileSuggest.SetCWD(d.cwd)
985 if popup := d.fileSuggest.Render(d.newTaskEd.Value(), th, width); len(popup) > 0 {
986 out = append(out, popup...)
987 }
988 }
989 // Breathing-room blank row above the editor so the input
990 // doesn't sit flush against the frame header.
991 out = append(out, "")
992 if d.newTaskEd != nil {
993 edLines, _, _ := d.newTaskEd.Render(width - 2)
994 for _, l := range edLines {
995 out = append(out, " "+l)
996 }
997 }
998 // Matching blank row below before the hint, mirroring the
999 // main input's editor breathing room.
1000 out = append(out, "")
1001 out = append(out, " "+th.FG256(th.Muted, "enter spawn, /model pick model, @ file/dir picker, paste/drop paths become [file:] / [dir:] chips, esc cancel"))
1002 out = append(out, frameRule(th, width))
1003 return out
1004 }
1005 if len(d.rows) == 0 {
1006 out = append(out, " "+th.FG256(th.Muted, "no agents — press n to spawn one (or /swarm new <task>)"))
1007 out = append(out, frameRule(th, width))
1008 return out
1009 }
1010
1011 // Column header for readability.
1012 header := fmt.Sprintf(" %-9s %-26s %-8s %s", "STATUS", "ID", "AGE", "ACTIVITY")
1013 out = append(out, th.FG256(th.Muted, header))
1014
1015 for i, r := range d.rows {
1016 row := formatSwarmRow(r, width-2)
1017 if i == d.cursor {
1018 out = append(out, th.PadHighlight(" "+row, width))
1019 } else {
1020 out = append(out, " "+th.FG256(th.FG, row))
1021 }
1022 }
1023 out = append(out, frameRule(th, width))
1024 return out
1025}
1026
1027func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
1028 a := d.selected()
1029 if a == nil {
1030 d.viewing = false
1031 return d.Render(th, width)
1032 }
1033 header := []string{
1034 frameHeader(th, "swarm: "+a.ID+" (type to send, esc back)", width),
1035 " " + th.FG256(th.Muted, "task: "+a.Task),
1036 " " + th.FG256(th.Muted, "branch: "+a.Branch),
1037 " " + th.FG256(th.Muted, "dir: "+a.Dir),
1038 " " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
1039 }
1040 if a.Model != "" {
1041 modelLine := "model: " + a.Model
1042 if a.Provider != "" {
1043 modelLine += " (" + a.Provider + ")"
1044 }
1045 header = append(header, " "+th.FG256(th.Muted, modelLine))
1046 }
1047 if a.Err != "" {
1048 header = append(header, " "+th.FG256(th.Muted, "error: "+a.Err))
1049 }
1050 header = append(header,
1051 "",
1052 " "+th.FG256(th.Muted, "── transcript ──"),
1053 "",
1054 )
1055
1056 // Body: prefer the full Lines slice when present, fall back to
1057 // the Tail string for snapshots that only filled the summary.
1058 raw := a.Lines
1059 if len(raw) == 0 && a.Tail != "" {
1060 raw = strings.Split(a.Tail, "\n")
1061 }
1062 if len(raw) == 0 {
1063 header = append(header, " "+th.FG256(th.Muted, "(no transcript yet)"))
1064 header = d.appendTranscriptEditor(header, th, width, a)
1065 header = append(header, frameRule(th, width))
1066 return header
1067 }
1068
1069 // Re-render the flat log into proper chat blocks: user-bubble
1070 // rows for echoes, markdown-rendered prose for assistant turns,
1071 // tinted prefixes for stderr / errors. The result is a single
1072 // pre-styled, pre-indented []string of display rows ready to
1073 // hand to the host.
1074 //
1075 // We deliberately do NOT clip into a fixed-height viewport with
1076 // internal scroll — the transcript flows in full, /btw-style,
1077 // and the host's outer scrollback handles overflow naturally.
1078 // That gives the user the chat UX they expect (transcript grows
1079 // with the window, arrow keys belong to the editor, no "↑ N
1080 // more above" cliff).
1081 body := renderSwarmTranscriptBlocks(raw, th, width)
1082
1083 out := header
1084 out = append(out, body...)
1085
1086 // /btw-style inline editor pinned to the bottom of the transcript.
1087 // Rendered after the body so the editor sits flush above the
1088 // frame rule. Only shown for running agents (others have no live
1089 // inbox listener — we surface a hint instead).
1090 out = d.appendTranscriptEditor(out, th, width, a)
1091
1092 out = append(out, frameRule(th, width))
1093 return out
1094}
1095
1096// appendTranscriptEditor adds the always-on inline composer to the
1097// transcript view, mimicking the /btw side-chat layout: a breathing
1098// row, the editor itself, another breathing row, and a single muted
1099// hint. For non-running agents we render a short "can't send while
1100// X" note instead so the user knows why typing is disabled.
1101func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *swarm.AgentSnapshot) []string {
1102 if a == nil {
1103 return out
1104 }
1105 if a.Status != swarm.StatusRunning || d.send == nil {
1106 out = append(out, "")
1107 out = append(out, " "+th.FG256(th.Muted, promptDisabledHint(a.Status)))
1108 return out
1109 }
1110 // Lazy-init the editor here too so the very first render of the
1111 // transcript view (before the user has pressed any key) already
1112 // shows the composer. Mirrors openPromptEditor's setup but uses
1113 // the live theme so the accent bar matches.
1114 if d.promptEd == nil {
1115 d.openPromptEditor(th, a.ID)
1116 }
1117 if d.promptEd != nil {
1118 d.promptEd.Prompt = th.AccentBar(th.Accent)
1119 }
1120 if d.promptSuggest != nil {
1121 d.promptSuggest.SetCWD(d.cwd)
1122 if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
1123 out = append(out, popup...)
1124 }
1125 }
1126
1127 // Busy-spinner row, /btw-style. Shown whenever the agent's
1128 // current activity isn't "idle" — so "thinking", "tool: foo",
1129 // "starting", etc. all animate. The spinner is stopped (and the
1130 // row hidden) once the agent reports idle, mirroring the main
1131 // chat's busy line.
1132 if agentIsBusy(a) {
1133 if d.transcriptSpin == nil {
1134 d.transcriptSpin = newSpinner()
1135 d.transcriptSpin.Start()
1136 }
1137 out = append(out, "")
1138 prefix := fmt.Sprintf("%s %s, %s",
1139 th.FG256(th.Assistant, d.transcriptSpin.Frame()),
1140 th.FG256(th.Assistant, a.Activity),
1141 th.FG256(th.Muted, d.transcriptSpin.Elapsed().String()),
1142 )
1143 out = append(out, " "+prefix)
1144 } else {
1145 // Reset so the next busy phase starts fresh (rotating
1146 // funny-line cycles back to the start, elapsed restarts).
1147 d.transcriptSpin = nil
1148 }
1149
1150 out = append(out, "")
1151 edLines, _, _ := d.promptEd.Render(width - 2)
1152 for _, l := range edLines {
1153 out = append(out, " "+l)
1154 }
1155 out = append(out, "")
1156 out = append(out, " "+th.FG256(th.Muted, "enter send, @ file/dir picker, esc back"))
1157 return out
1158}
1159
1160// agentIsBusy reports whether the agent is currently mid-turn and
1161// should render a spinner. Conservative: only known idle markers
1162// suppress the spinner so transient / unfamiliar activity strings
1163// still animate. Detached / done / failed / killed never animate —
1164// they aren't actively producing output.
1165func agentIsBusy(a *swarm.AgentSnapshot) bool {
1166 if a == nil {
1167 return false
1168 }
1169 if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {
1170 return false
1171 }
1172 switch strings.TrimSpace(a.Activity) {
1173 case "", "idle":
1174 return false
1175 }
1176 return true
1177}
1178
1179// renderSwarmTranscriptBlocks converts the agent's flat transcript
1180// log into a slice of display rows styled like the main chat: user
1181// echoes in bubble rows, assistant prose through tui.RenderMarkdown,
1182// stderr / error chatter tinted. Each output row is fully indented
1183// and ready for the caller to slice into a scroll window.
1184//
1185// The flat log mixes role markers and raw text, so we group adjacent
1186// lines of the same role into one logical "block" and render the
1187// whole block together — essential for markdown, which needs the
1188// full message body in one piece to detect fenced code spans,
1189// bullet lists, and so on.
1190func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int) []string {
1191 type blockKind int
1192 const (
1193 kindAssistant blockKind = iota
1194 kindUser
1195 kindStderr
1196 kindError
1197 )
1198 type block struct {
1199 kind blockKind
1200 body []string // raw lines, role prefix already stripped
1201 }
1202
1203 // classify maps one raw line to its role and the stripped body.
1204 classify := func(s string) (blockKind, string) {
1205 switch {
1206 case strings.HasPrefix(s, "user: "):
1207 return kindUser, strings.TrimPrefix(s, "user: ")
1208 case strings.HasPrefix(s, "stderr: "):
1209 return kindStderr, strings.TrimPrefix(s, "stderr: ")
1210 case strings.HasPrefix(s, "error: "):
1211 return kindError, strings.TrimPrefix(s, "error: ")
1212 default:
1213 return kindAssistant, s
1214 }
1215 }
1216
1217 // Coalesce consecutive same-kind lines into blocks. Empty lines
1218 // keep the kind of the previous block so blank lines inside an
1219 // assistant message stay attached for markdown rendering (an
1220 // empty line between paragraphs is significant in markdown).
1221 var blocks []block
1222 for _, ln := range lines {
1223 if ln == "" {
1224 // Empty lines stay attached to the previous block so
1225 // markdown sees paragraph breaks inside an assistant
1226 // turn. Standalone empties before any block are dropped
1227 // (nothing meaningful to attach them to).
1228 if len(blocks) > 0 {
1229 blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, "")
1230 }
1231 continue
1232 }
1233 k, stripped := classify(ln)
1234 if len(blocks) == 0 || blocks[len(blocks)-1].kind != k {
1235 blocks = append(blocks, block{kind: k})
1236 }
1237 blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, stripped)
1238 }
1239
1240 // Render each block. Width budget mirrors the btw dialog: the
1241 // transcript text sits inside two leading frame cells, so the
1242 // inner width is width-4 for markdown and width-2 for the
1243 // user-bubble (the bubble already pads itself).
1244 var out []string
1245 innerMD := width - 4
1246 if innerMD < 20 {
1247 innerMD = 20
1248 }
1249 bubbleWidth := width - 2
1250 if bubbleWidth < 10 {
1251 bubbleWidth = 10
1252 }
1253
1254 for i, b := range blocks {
1255 if i > 0 {
1256 out = append(out, "") // breathing room between turns
1257 }
1258 switch b.kind {
1259 case kindUser:
1260 text := strings.Join(b.body, "\n")
1261 out = append(out, btwUserBubbleRows(th, text, bubbleWidth)...)
1262 case kindAssistant:
1263 text := strings.Join(b.body, "\n")
1264 md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD)
1265 for _, line := range strings.Split(md, "\n") {
1266 if len(line) > 0 && line[0] == tui.FlushLeftSentinel {
1267 line = line[1:]
1268 }
1269 out = append(out, " "+line)
1270 }
1271 case kindStderr:
1272 for _, line := range b.body {
1273 out = append(out, " "+th.FG256(th.Muted, "stderr "+line))
1274 }
1275 case kindError:
1276 for _, line := range b.body {
1277 out = append(out, " "+th.FG256(th.Error, "✖ "+line))
1278 }
1279 }
1280 }
1281 return out
1282}
1283
1284// renderPromptEditor draws the inline "send a follow-up prompt"
1285// composer. Reuses the editor + file-suggest popup styling of the
1286// spawn editor so the two flows are visually consistent. `out` is
1287// the pre-built header (frame header for list view, or the agent
1288// metadata block for transcript view); we append onto it and close
1289// with the standard frame rule.
1290func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string {
1291 if d.promptEd == nil {
1292 d.openPromptEditor(th, d.promptTargetID)
1293 }
1294 if d.promptEd != nil {
1295 d.promptEd.Prompt = th.AccentBar(th.Accent)
1296 }
1297 target := d.promptTargetID
1298 if target == "" {
1299 target = "<unknown>"
1300 }
1301 out = append(out, " "+th.FG256(th.Muted, "send to "+target+":"))
1302 if d.promptSuggest != nil {
1303 d.promptSuggest.SetCWD(d.cwd)
1304 if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
1305 out = append(out, popup...)
1306 }
1307 }
1308 // Breathing-room blank row above the editor.
1309 out = append(out, "")
1310 if d.promptEd != nil {
1311 edLines, _, _ := d.promptEd.Render(width - 2)
1312 for _, l := range edLines {
1313 out = append(out, " "+l)
1314 }
1315 }
1316 // Matching blank row below before the hint.
1317 out = append(out, "")
1318 out = append(out, " "+th.FG256(th.Muted, "enter send, @ file/dir picker, esc cancel"))
1319 out = append(out, frameRule(th, width))
1320 return out
1321}
1322
1323// formatSwarmRow is the one-line summary shown per agent.
1324func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
1325 status := statusLabel(r.Status)
1326 age := formatAge(r.Started)
1327 left := fmt.Sprintf("%-9s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age)
1328 room := maxWidth - len([]rune(left))
1329 if room < 10 {
1330 room = 10
1331 }
1332 act := strings.ReplaceAll(r.Activity, "\n", " ")
1333 if act == "" {
1334 act = r.Task
1335 }
1336 if len([]rune(act)) > room {
1337 act = string([]rune(act)[:room-1]) + "…"
1338 }
1339 row := left + act
1340 rowRunes := []rune(row)
1341 if len(rowRunes) > maxWidth {
1342 row = string(rowRunes[:maxWidth-1]) + "…"
1343 }
1344 return row
1345}
1346
1347// statusLabel returns a short, padded status badge. Kept stable across
1348// runs (no colours embedded) so tests can match it deterministically.
1349func statusLabel(s swarm.Status) string {
1350 switch s {
1351 case swarm.StatusPending:
1352 return "● pend"
1353 case swarm.StatusRunning:
1354 return "● run"
1355 case swarm.StatusDone:
1356 return "✓ done"
1357 case swarm.StatusFailed:
1358 return "✗ fail"
1359 case swarm.StatusKilled:
1360 return "■ kill"
1361 case swarm.StatusDetached:
1362 return "○ detach"
1363 }
1364 return string(s)
1365}
1366
1367func formatAge(t time.Time) string {
1368 if t.IsZero() {
1369 return "-"
1370 }
1371 d := time.Since(t)
1372 switch {
1373 case d < time.Minute:
1374 return fmt.Sprintf("%ds", int(d.Seconds()))
1375 case d < time.Hour:
1376 return fmt.Sprintf("%dm", int(d.Minutes()))
1377 case d < 24*time.Hour:
1378 return fmt.Sprintf("%dh", int(d.Hours()))
1379 default:
1380 return fmt.Sprintf("%dd", int(d.Hours()/24))
1381 }
1382}