v0.4.0 a622146

subagents_dialog.go RawHistoryFolder

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

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