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