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