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