v0.14.0 75d4151

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

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