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