v0.3.54 4441e91

swarm_dialog.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close