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