v0.2.13 84fd98e

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

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