v0.2.51 92241f6

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

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