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