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