v0.2.69 c6b35a2

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		t := strings.TrimSpace(ln)
703		if t == "/model" || strings.HasPrefix(t, "/model ") {
704			return true
705		}
706	}
707	return false
708}
709
710// stripSlashModelLine removes the line containing "/model" (with
711// optional trailing args) from the spawn-editor buffer so the
712// remainder can be restored after the picker closes. The /model line
713// is always exactly one full line in practice — the user typed
714// /model<Enter> — but we tolerate inline placement at the start of
715// a line just in case.
716func stripSlashModelLine(buf string) string {
717	lines := strings.Split(buf, "\n")
718	out := lines[:0]
719	for _, ln := range lines {
720		t := strings.TrimSpace(ln)
721		if t == "/model" || strings.HasPrefix(t, "/model ") {
722			continue
723		}
724		out = append(out, ln)
725	}
726	return strings.TrimRight(strings.Join(out, "\n"), "\n")
727}
728
729// openPromptEditor enters the inline "send a follow-up prompt" editor
730// for the agent with id targetID. Mirrors openSpawnEditor so the
731// editing experience (paste, @-picker, drop chips) is identical.
732func (d *swarmDialog) openPromptEditor(th tui.Theme, targetID string) {
733	d.prompting = true
734	d.promptEd = tui.NewEditor(d.editorPrompt(th))
735	d.promptSuggest = newFileSuggester()
736	d.promptSuggest.SetCWD(d.cwd)
737	d.promptTargetID = targetID
738}
739
740// handlePromptKey owns the keystrokes while the inline prompt editor
741// is open. It mirrors handleSpawnKey but, on submit, invokes d.send
742// with the captured target id instead of d.spawn. Esc cancels and
743// returns the user to whichever view (list or transcript) was active
744// when 'p' was pressed.
745func (d *swarmDialog) handlePromptKey(k tui.Key) (closed bool, msg, errMsg string) {
746	if d.promptEd == nil {
747		d.openPromptEditor(tui.Theme{}, d.promptTargetID)
748	}
749	ed := d.promptEd
750	fs := d.promptSuggest
751	if fs != nil {
752		fs.SetCWD(d.cwd)
753	}
754
755	// File suggestions: same keys and chip shape as the default input.
756	if fs != nil && fs.Active(ed.Value()) {
757		switch k.Kind {
758		case tui.KeyUp:
759			fs.Up()
760			return false, "", ""
761		case tui.KeyDown:
762			fs.Down()
763			return false, "", ""
764		case tui.KeyRight:
765			fs.Right()
766			return false, "", ""
767		case tui.KeyLeft:
768			fs.Left()
769			return false, "", ""
770		case tui.KeyEnter:
771			if entry, ok := fs.SelectedEntry(ed.Value()); ok {
772				chip := "[file:" + entry.rel + "]"
773				if entry.isDir {
774					chip = "[dir:" + entry.rel + "/]"
775				}
776				val := ed.Value()
777				if idx := strings.LastIndex(val, "@"); idx >= 0 {
778					val = val[:idx]
779				}
780				ed.SetValue(val + chip + " ")
781				fs.Reset()
782			}
783			return false, "", ""
784		case tui.KeyEsc:
785			val := ed.Value()
786			if idx := strings.LastIndex(val, "@"); idx >= 0 {
787				ed.SetValue(val[:idx])
788			}
789			fs.Reset()
790			return false, "", ""
791		}
792	}
793
794	// Esc cancels the prompt editor when the file picker isn't active.
795	if k.Kind == tui.KeyEsc {
796		d.closePromptEditor()
797		return false, "", ""
798	}
799
800	// Tab-complete a path-like token before the editor sees the key,
801	// matching the main editor's behaviour. Skipped above by the
802	// @-picker branch when its popup is active.
803	if k.Kind == tui.KeyTab {
804		if tryPathTabCompleteEditor(ed, d.cwd) {
805			return false, "", ""
806		}
807	}
808
809	if submitted := ed.HandleKey(k); submitted {
810		text := strings.TrimRight(ed.SubmitValue(), "\n")
811		text = expandFileChips(text, d.cwd)
812		text = strings.TrimSpace(text)
813		targetID := d.promptTargetID
814
815		// Empty submit: nothing to send. If we're in transcript
816		// view (always-on editor), just clear and stay; otherwise
817		// (list-view-p flow) close the modal.
818		if text == "" {
819			if d.viewing {
820				ed.Clear()
821				return false, "", ""
822			}
823			d.closePromptEditor()
824			return false, "", ""
825		}
826		if d.send == nil {
827			if !d.viewing {
828				d.closePromptEditor()
829			}
830			return false, "", "send not wired"
831		}
832		if targetID == "" {
833			if !d.viewing {
834				d.closePromptEditor()
835			}
836			return false, "", "send: no target agent"
837		}
838		if err := d.send(targetID, text); err != nil {
839			if !d.viewing {
840				d.closePromptEditor()
841			}
842			return false, "", friendlySendErr(targetID, err)
843		}
844
845		// In transcript view (always-on editor) keep the editor
846		// mounted and clear the buffer for the next message; in the
847		// list-view modal flow, close it so the user returns to the
848		// list. Either way, snap the transcript to the tail so the
849		// user sees their just-sent message and the agent's reply.
850		if d.viewing {
851			ed.Clear()
852		} else {
853			d.closePromptEditor()
854		}
855		return false, "sent to " + targetID, ""
856	}
857	return false, "", ""
858}
859
860// closePromptEditor resets all prompt-editor state. Called on Esc and
861// after submit so a subsequent 'p' starts from a clean slate.
862func (d *swarmDialog) closePromptEditor() {
863	d.prompting = false
864	d.promptEd = nil
865	d.promptSuggest = nil
866	d.promptTargetID = ""
867}
868
869// handleSpawnKey owns the keystrokes while the inline new-agent editor
870// is open. It mirrors the normal input's @ file-picker path and submit
871// expansion, then asks the host to spawn.
872func (d *swarmDialog) handleSpawnKey(k tui.Key) (closed bool, msg, errMsg string) {
873	if d.newTaskEd == nil {
874		d.openSpawnEditor(tui.Theme{})
875	}
876	ed := d.newTaskEd
877	fs := d.fileSuggest
878	if fs != nil {
879		fs.SetCWD(d.cwd)
880	}
881
882	// File suggestions: same keys and chip shape as the default input.
883	if fs != nil && fs.Active(ed.Value()) {
884		switch k.Kind {
885		case tui.KeyUp:
886			fs.Up()
887			return false, "", ""
888		case tui.KeyDown:
889			fs.Down()
890			return false, "", ""
891		case tui.KeyRight:
892			fs.Right()
893			return false, "", ""
894		case tui.KeyLeft:
895			fs.Left()
896			return false, "", ""
897		case tui.KeyEnter:
898			if entry, ok := fs.SelectedEntry(ed.Value()); ok {
899				chip := "[file:" + entry.rel + "]"
900				if entry.isDir {
901					chip = "[dir:" + entry.rel + "/]"
902				}
903				val := ed.Value()
904				if idx := strings.LastIndex(val, "@"); idx >= 0 {
905					val = val[:idx]
906				}
907				ed.SetValue(val + chip + " ")
908				fs.Reset()
909			}
910			return false, "", ""
911		case tui.KeyEsc:
912			val := ed.Value()
913			if idx := strings.LastIndex(val, "@"); idx >= 0 {
914				ed.SetValue(val[:idx])
915			}
916			fs.Reset()
917			return false, "", ""
918		}
919	}
920
921	// Esc cancels the spawn prompt when the file picker isn't active.
922	if k.Kind == tui.KeyEsc {
923		d.spawning = false
924		d.newTaskEd = nil
925		d.fileSuggest = nil
926		return false, "", ""
927	}
928
929	// Tab-complete a path-like token before the editor sees the key,
930	// matching the main editor's behaviour. Skipped above by the
931	// @-picker branch when its popup is active.
932	if k.Kind == tui.KeyTab {
933		if tryPathTabCompleteEditor(ed, d.cwd) {
934			return false, "", ""
935		}
936	}
937
938	if submitted := ed.HandleKey(k); submitted {
939		raw := strings.TrimRight(ed.SubmitValue(), "\n")
940
941		// In-editor /model command: when the buffer contains a line
942		// whose trimmed content is "/model" (anywhere — typically on
943		// its own line, but a trailing /model after a task works
944		// too), pop the same picker the global /model uses, drop the
945		// /model line, and preserve the rest of the buffer so the
946		// user can keep composing once they've picked.
947		if containsSlashModelLine(raw) {
948			d.spawnDraft = stripSlashModelLine(raw)
949			d.openModelPicker()
950			return false, "", ""
951		}
952
953		task := expandFileChips(raw, d.cwd)
954		task = strings.TrimSpace(task)
955		model := d.pendingModel
956		provider := d.pendingProvider
957		d.spawning = false
958		d.newTaskEd = nil
959		d.fileSuggest = nil
960		d.spawnDraft = ""
961		d.pendingModel = ""
962		d.pendingProvider = ""
963		if task == "" {
964			return false, "", ""
965		}
966		if d.spawn == nil {
967			return false, "", "spawn not wired"
968		}
969		if err := d.spawn(task, model, provider); err != nil {
970			return false, "", "spawn: " + err.Error()
971		}
972		if model != "" {
973			return false, "spawned (model " + model + ")", ""
974		}
975		return false, "spawned", ""
976	}
977	return false, "", ""
978}
979
980func (d *swarmDialog) selected() *swarm.AgentSnapshot {
981	if d.cursor < 0 || d.cursor >= len(d.rows) {
982		return nil
983	}
984	r := d.rows[d.cursor]
985	return &r
986}
987
988// Render returns the dialog lines. Re-reads the snapshot each call so
989// the dashboard updates as agents make progress.
990func (d *swarmDialog) Render(th tui.Theme, width int) []string {
991	if !d.Active() {
992		return nil
993	}
994	d.refresh()
995
996	if d.viewing {
997		return d.renderTranscript(th, width)
998	}
999
1000	out := []string{frameHeader(th, "swarm (n new, p prompt, R resume, ↑/↓ move, enter view, k kill, r remove, esc close)", width)}
1001	if d.prompting {
1002		return d.renderPromptEditor(th, width, out)
1003	}
1004	if d.pickingModel {
1005		// modelDialog renders its own frame; replace ours so the
1006		// picker takes the dashboard cleanly. Trailing hint reminds
1007		// the user Esc returns to the spawn editor.
1008		if d.modelPicker == nil {
1009			d.openModelPicker()
1010		}
1011		out = d.modelPicker.Render(th, width)
1012		out = append(out, "  "+th.FG256(th.Muted, "select model for next spawn, esc to cancel"))
1013		return out
1014	}
1015	if d.spawning {
1016		if d.newTaskEd == nil {
1017			d.openSpawnEditor(th)
1018		}
1019		if d.newTaskEd != nil {
1020			d.newTaskEd.Prompt = d.editorPrompt(th)
1021		}
1022		// One-line banner reminding the user which model the
1023		// agent-to-be will run against. Skipped when no model was
1024		// picked so the layout matches the historical look.
1025		if d.pendingModel != "" {
1026			label := "model: " + d.pendingModel
1027			if d.pendingProvider != "" {
1028				label += " (" + d.pendingProvider + ")"
1029			}
1030			out = append(out, "  "+th.FG256(th.Accent, label))
1031		}
1032		if d.fileSuggest != nil {
1033			d.fileSuggest.SetCWD(d.cwd)
1034			if popup := d.fileSuggest.Render(d.newTaskEd.Value(), th, width); len(popup) > 0 {
1035				out = append(out, popup...)
1036			}
1037		}
1038		// Breathing-room blank row above the editor so the input
1039		// doesn't sit flush against the frame header.
1040		out = append(out, "")
1041		if d.newTaskEd != nil {
1042			edLines, _, _ := d.newTaskEd.Render(width - 2)
1043			for _, l := range edLines {
1044				out = append(out, "  "+l)
1045			}
1046		}
1047		// Matching blank row below before the hint, mirroring the
1048		// main input's editor breathing room.
1049		out = append(out, "")
1050		out = append(out, "  "+th.FG256(th.Muted, "enter spawn, /model pick model, @ file/dir picker, paste/drop paths become [file:] / [dir:] chips, esc cancel"))
1051		out = append(out, frameRule(th, width))
1052		return out
1053	}
1054	if len(d.rows) == 0 {
1055		out = append(out, "  "+th.FG256(th.Muted, "no agents — press n to spawn one (or /swarm new <task>)"))
1056		out = append(out, frameRule(th, width))
1057		return out
1058	}
1059
1060	// Column header for readability.
1061	header := fmt.Sprintf("  %-9s  %-26s  %-8s  %s", "STATUS", "ID", "AGE", "ACTIVITY")
1062	out = append(out, th.FG256(th.Muted, header))
1063
1064	for i, r := range d.rows {
1065		row := formatSwarmRow(r, width-2)
1066		if i == d.cursor {
1067			out = append(out, th.PadHighlight("  "+row, width))
1068		} else {
1069			out = append(out, "  "+th.FG256(th.FG, row))
1070		}
1071	}
1072	out = append(out, frameRule(th, width))
1073	return out
1074}
1075
1076func (d *swarmDialog) renderTranscript(th tui.Theme, width int) []string {
1077	a := d.selected()
1078	if a == nil {
1079		d.viewing = false
1080		return d.Render(th, width)
1081	}
1082	header := []string{
1083		frameHeader(th, "swarm: "+a.ID+"  (type to send, esc back)", width),
1084		"  " + th.FG256(th.Muted, "task:   "+a.Task),
1085		"  " + th.FG256(th.Muted, "dir:    "+a.Dir),
1086		"  " + th.FG256(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
1087	}
1088	if a.Model != "" {
1089		modelLine := "model:  " + a.Model
1090		if a.Provider != "" {
1091			modelLine += " (" + a.Provider + ")"
1092		}
1093		header = append(header, "  "+th.FG256(th.Muted, modelLine))
1094	}
1095	if a.Err != "" {
1096		header = append(header, "  "+th.FG256(th.Muted, "error:  "+a.Err))
1097	}
1098	header = append(header,
1099		"",
1100		"  "+th.FG256(th.Muted, "── transcript ──"),
1101		"",
1102	)
1103
1104	// Body: prefer the full Lines slice when present, fall back to
1105	// the Tail string for snapshots that only filled the summary.
1106	raw := a.Lines
1107	if len(raw) == 0 && a.Tail != "" {
1108		raw = strings.Split(a.Tail, "\n")
1109	}
1110	if len(raw) == 0 {
1111		header = append(header, "  "+th.FG256(th.Muted, "(no transcript yet)"))
1112		header = d.appendTranscriptEditor(header, th, width, a)
1113		header = append(header, frameRule(th, width))
1114		return header
1115	}
1116
1117	// Re-render the flat log into proper chat blocks: user-bubble
1118	// rows for echoes, markdown-rendered prose for assistant turns,
1119	// tinted prefixes for stderr / errors. The result is a single
1120	// pre-styled, pre-indented []string of display rows ready to
1121	// hand to the host.
1122	//
1123	// We deliberately do NOT clip into a fixed-height viewport with
1124	// internal scroll — the transcript flows in full, /btw-style,
1125	// and the host's outer scrollback handles overflow naturally.
1126	// That gives the user the chat UX they expect (transcript grows
1127	// with the window, arrow keys belong to the editor, no "↑ N
1128	// more above" cliff).
1129	body := renderSwarmTranscriptBlocks(raw, th, width, d.compactMode)
1130
1131	out := header
1132	out = append(out, body...)
1133
1134	// /btw-style inline editor pinned to the bottom of the transcript.
1135	// Rendered after the body so the editor sits flush above the
1136	// frame rule. Only shown for running agents (others have no live
1137	// inbox listener — we surface a hint instead).
1138	out = d.appendTranscriptEditor(out, th, width, a)
1139
1140	out = append(out, frameRule(th, width))
1141	return out
1142}
1143
1144// appendTranscriptEditor adds the always-on inline composer to the
1145// transcript view, mimicking the /btw side-chat layout: a breathing
1146// row, the editor itself, another breathing row, and a single muted
1147// hint. For non-running agents we render a short "can't send while
1148// X" note instead so the user knows why typing is disabled.
1149func (d *swarmDialog) appendTranscriptEditor(out []string, th tui.Theme, width int, a *swarm.AgentSnapshot) []string {
1150	if a == nil {
1151		return out
1152	}
1153	if a.Status != swarm.StatusRunning || d.send == nil {
1154		out = append(out, "")
1155		out = append(out, "  "+th.FG256(th.Muted, promptDisabledHint(a.Status)))
1156		return out
1157	}
1158	// Lazy-init the editor here too so the very first render of the
1159	// transcript view (before the user has pressed any key) already
1160	// shows the composer. Mirrors openPromptEditor's setup but uses
1161	// the live theme so the accent bar matches.
1162	if d.promptEd == nil {
1163		d.openPromptEditor(th, a.ID)
1164	}
1165	if d.promptEd != nil {
1166		d.promptEd.Prompt = d.editorPrompt(th)
1167	}
1168	if d.promptSuggest != nil {
1169		d.promptSuggest.SetCWD(d.cwd)
1170		if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
1171			out = append(out, popup...)
1172		}
1173	}
1174
1175	// Busy-spinner row, /btw-style. Shown whenever the agent's
1176	// current activity isn't "idle" — so "thinking", "tool: foo",
1177	// "starting", etc. all animate. The spinner is stopped (and the
1178	// row hidden) once the agent reports idle, mirroring the main
1179	// chat's busy line.
1180	if agentIsBusy(a) {
1181		if d.transcriptSpin == nil {
1182			d.transcriptSpin = newSpinner(th)
1183			d.transcriptSpin.Start()
1184		} else {
1185			d.transcriptSpin.Configure(th)
1186		}
1187		out = append(out, "")
1188		prefix := fmt.Sprintf("%s %s, %s",
1189			th.FG256(th.Assistant, d.transcriptSpin.Frame()),
1190			th.FG256(th.Assistant, a.Activity),
1191			th.FG256(th.Muted, d.transcriptSpin.Elapsed().String()),
1192		)
1193		out = append(out, "  "+prefix)
1194	} else {
1195		// Reset so the next busy phase starts fresh (rotating
1196		// funny-line cycles back to the start, elapsed restarts).
1197		d.transcriptSpin = nil
1198	}
1199
1200	out = append(out, "")
1201	edLines, _, _ := d.promptEd.Render(width - 2)
1202	for _, l := range edLines {
1203		out = append(out, "  "+l)
1204	}
1205	out = append(out, "")
1206	out = append(out, "  "+th.FG256(th.Muted, "enter send, @ file/dir picker, esc back"))
1207	return out
1208}
1209
1210// agentIsBusy reports whether the agent is currently mid-turn and
1211// should render a spinner. Conservative: only known idle markers
1212// suppress the spinner so transient / unfamiliar activity strings
1213// still animate. Detached / done / failed / killed never animate —
1214// they aren't actively producing output.
1215func agentIsBusy(a *swarm.AgentSnapshot) bool {
1216	if a == nil {
1217		return false
1218	}
1219	if a.Status != swarm.StatusRunning && a.Status != swarm.StatusPending {
1220		return false
1221	}
1222	switch strings.TrimSpace(a.Activity) {
1223	case "", "idle":
1224		return false
1225	}
1226	return true
1227}
1228
1229// renderSwarmTranscriptBlocks converts the agent's flat transcript
1230// log into a slice of display rows styled like the main chat: user
1231// echoes in bubble rows, assistant prose through tui.RenderMarkdown,
1232// stderr / error chatter tinted. Each output row is fully indented
1233// and ready for the caller to slice into a scroll window.
1234//
1235// The flat log mixes role markers and raw text, so we group adjacent
1236// lines of the same role into one logical "block" and render the
1237// whole block together — essential for markdown, which needs the
1238// full message body in one piece to detect fenced code spans,
1239// bullet lists, and so on.
1240func renderSwarmTranscriptBlocks(lines []string, th tui.Theme, width int, compactMode bool) []string {
1241	type blockKind int
1242	const (
1243		kindAssistant blockKind = iota
1244		kindUser
1245		kindStderr
1246		kindError
1247	)
1248	type block struct {
1249		kind blockKind
1250		body []string // raw lines, role prefix already stripped
1251	}
1252
1253	// classify maps one raw line to its role and the stripped body.
1254	classify := func(s string) (blockKind, string) {
1255		switch {
1256		case strings.HasPrefix(s, "user: "):
1257			return kindUser, strings.TrimPrefix(s, "user: ")
1258		case strings.HasPrefix(s, "stderr: "):
1259			return kindStderr, strings.TrimPrefix(s, "stderr: ")
1260		case strings.HasPrefix(s, "error: "):
1261			return kindError, strings.TrimPrefix(s, "error: ")
1262		default:
1263			return kindAssistant, s
1264		}
1265	}
1266
1267	// Coalesce consecutive same-kind lines into blocks. Empty lines
1268	// keep the kind of the previous block so blank lines inside an
1269	// assistant message stay attached for markdown rendering (an
1270	// empty line between paragraphs is significant in markdown).
1271	var blocks []block
1272	for _, ln := range lines {
1273		if ln == "" {
1274			// Empty lines stay attached to the previous block so
1275			// markdown sees paragraph breaks inside an assistant
1276			// turn. Standalone empties before any block are dropped
1277			// (nothing meaningful to attach them to).
1278			if len(blocks) > 0 {
1279				blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, "")
1280			}
1281			continue
1282		}
1283		k, stripped := classify(ln)
1284		if len(blocks) == 0 || blocks[len(blocks)-1].kind != k {
1285			blocks = append(blocks, block{kind: k})
1286		}
1287		blocks[len(blocks)-1].body = append(blocks[len(blocks)-1].body, stripped)
1288	}
1289
1290	// Render each block. Width budget mirrors the btw dialog: the
1291	// transcript text sits inside two leading frame cells, so the
1292	// inner width is width-4 for markdown and width-2 for the
1293	// user-bubble (the bubble already pads itself).
1294	var out []string
1295	innerMD := width - 4
1296	if innerMD < 20 {
1297		innerMD = 20
1298	}
1299	bubbleWidth := width - 2
1300	if bubbleWidth < 10 {
1301		bubbleWidth = 10
1302	}
1303
1304	for i, b := range blocks {
1305		if i > 0 {
1306			out = append(out, "") // breathing room between turns
1307		}
1308		switch b.kind {
1309		case kindUser:
1310			text := strings.Join(b.body, "\n")
1311			out = append(out, btwUserBubbleRows(th, text, bubbleWidth, compactMode)...)
1312		case kindAssistant:
1313			text := strings.Join(b.body, "\n")
1314			md := tui.RenderMarkdown(strings.TrimLeft(text, "\n"), th, innerMD)
1315			for _, line := range strings.Split(md, "\n") {
1316				if len(line) > 0 && line[0] == tui.FlushLeftSentinel {
1317					line = line[1:]
1318				}
1319				out = append(out, "    "+line)
1320			}
1321		case kindStderr:
1322			for _, line := range b.body {
1323				out = append(out, "    "+th.FG256(th.Muted, "stderr  "+line))
1324			}
1325		case kindError:
1326			for _, line := range b.body {
1327				out = append(out, "    "+th.FG256(th.Error, "✖ "+line))
1328			}
1329		}
1330	}
1331	return out
1332}
1333
1334// renderPromptEditor draws the inline "send a follow-up prompt"
1335// composer. Reuses the editor + file-suggest popup styling of the
1336// spawn editor so the two flows are visually consistent. `out` is
1337// the pre-built header (frame header for list view, or the agent
1338// metadata block for transcript view); we append onto it and close
1339// with the standard frame rule.
1340func (d *swarmDialog) renderPromptEditor(th tui.Theme, width int, out []string) []string {
1341	if d.promptEd == nil {
1342		d.openPromptEditor(th, d.promptTargetID)
1343	}
1344	if d.promptEd != nil {
1345		d.promptEd.Prompt = d.editorPrompt(th)
1346	}
1347	target := d.promptTargetID
1348	if target == "" {
1349		target = "<unknown>"
1350	}
1351	out = append(out, "  "+th.FG256(th.Muted, "send to "+target+":"))
1352	if d.promptSuggest != nil {
1353		d.promptSuggest.SetCWD(d.cwd)
1354		if popup := d.promptSuggest.Render(d.promptEd.Value(), th, width); len(popup) > 0 {
1355			out = append(out, popup...)
1356		}
1357	}
1358	// Breathing-room blank row above the editor.
1359	out = append(out, "")
1360	if d.promptEd != nil {
1361		edLines, _, _ := d.promptEd.Render(width - 2)
1362		for _, l := range edLines {
1363			out = append(out, "  "+l)
1364		}
1365	}
1366	// Matching blank row below before the hint.
1367	out = append(out, "")
1368	out = append(out, "  "+th.FG256(th.Muted, "enter send, @ file/dir picker, esc cancel"))
1369	out = append(out, frameRule(th, width))
1370	return out
1371}
1372
1373// formatSwarmRow is the one-line summary shown per agent.
1374//
1375// Layout (fixed-width columns, then free-form activity):
1376//
1377//	STATUS    ID                          AGE       ACTIVITY
1378//	● run     fix-login-12345             3m        editing main.go
1379//	✓ done    write-tests-67890           1h        done
1380func formatSwarmRow(r swarm.AgentSnapshot, maxWidth int) string {
1381	status := statusLabel(r.Status)
1382	age := formatAge(r.Started)
1383	left := fmt.Sprintf("%-9s  %-26s  %-8s  ", status, truncateLineSafe(r.ID, 26), age)
1384	room := maxWidth - len([]rune(left))
1385	if room < 10 {
1386		room = 10
1387	}
1388	act := strings.ReplaceAll(r.Activity, "\n", " ")
1389	if act == "" {
1390		act = r.Task
1391	}
1392	if len([]rune(act)) > room {
1393		act = string([]rune(act)[:room-3]) + "..."
1394	}
1395	row := left + act
1396	rowRunes := []rune(row)
1397	if len(rowRunes) > maxWidth {
1398		if maxWidth <= 3 {
1399			row = strings.Repeat(".", maxWidth)
1400		} else {
1401			row = string(rowRunes[:maxWidth-3]) + "..."
1402		}
1403	}
1404	return row
1405}
1406
1407// statusLabel returns a short, padded status badge. Kept stable across
1408// runs (no colours embedded) so tests can match it deterministically.
1409func statusLabel(s swarm.Status) string {
1410	switch s {
1411	case swarm.StatusPending:
1412		return "● pend"
1413	case swarm.StatusRunning:
1414		return "● run"
1415	case swarm.StatusDone:
1416		return "✓ done"
1417	case swarm.StatusFailed:
1418		return "✗ fail"
1419	case swarm.StatusKilled:
1420		return "■ kill"
1421	case swarm.StatusDetached:
1422		return "○ detach"
1423	}
1424	return string(s)
1425}
1426
1427func formatAge(t time.Time) string {
1428	if t.IsZero() {
1429		return "-"
1430	}
1431	d := time.Since(t)
1432	switch {
1433	case d < time.Minute:
1434		return fmt.Sprintf("%ds", int(d.Seconds()))
1435	case d < time.Hour:
1436		return fmt.Sprintf("%dm", int(d.Minutes()))
1437	case d < 24*time.Hour:
1438		return fmt.Sprintf("%dh", int(d.Hours()))
1439	default:
1440		return fmt.Sprintf("%dd", int(d.Hours()/24))
1441	}
1442}

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