v0.1.82 25e7da2

swarm_dialog.go RawHistoryFolder

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

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