v0.12.1 b9ff687

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

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 476

↑↓ moveenter openesc close