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}