v0.1.92 cd4484d

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

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