package tui import ( "fmt" "strings" "time" ) // This file renders the multi-line sub-agent status panel (SPEC 4.4, US-006): a // block shown just above the working spinner while one or more sub-agents // dispatched by the `task` tool are running. Each active sub-agent contributes // exactly one status line of the form: // // ⏺ {desc} · {activity} ({elapsed} · ↓{tokens}) // // The panel is also interactive: while the input box is empty, ↑/↓ move a // selection cursor over the rows and Enter expands the selected row to show that // sub-agent's accumulated text output inline (below its status line), Esc // collapses. The panel is a pure function of the model's ordered active-subagent // set plus its selection state; it is re-rendered every spinner tick so the // elapsed clock stays live without a dedicated timer. When there are no active // sub-agents it renders nothing (zero lines, zero height), leaving the existing // single-run layout untouched. // maxExpandedLines caps how many wrapped output lines an expanded row shows. The // output can grow without bound, so only the most recent lines are kept visible; // older content scrolls off the top of the inline pane. const maxExpandedLines = 12 // subagentRow is one live sub-agent's status, keyed by the parent task tool-call // id. start is recorded when the row is added so elapsed can be computed at // render time; activity/tokens are refreshed by subagentProgressMsg; output // accumulates the sub-agent's forwarded text (toolUpdate deltas + final result) // for the inline expanded view. type subagentRow struct { id string desc string activity string tokens int start time.Time output string } // subagentPanel is the ordered set of live sub-agents. order preserves insertion // order (so rows render stably, oldest first) while byID gives O(1) lookup for // progress updates and removal. selecting reports whether a row is cursored; // selected is that row's index into order (meaningful only while selecting is // true); expanded reports whether the selected row shows its output inline. The // zero value is a valid empty, unselected panel — selecting defaults false so the // selected int's zero value never spuriously marks row 0. type subagentPanel struct { order []string byID map[string]*subagentRow selecting bool selected int expanded bool } // add records a newly dispatched sub-agent (a toolStartMsg with name=="task"). // It is idempotent on the id: a duplicate start refreshes the description and // resets the start clock rather than adding a second row. func (p *subagentPanel) add(id, desc string, now time.Time) { if p.byID == nil { p.byID = make(map[string]*subagentRow) } if row, ok := p.byID[id]; ok { row.desc = desc row.start = now return } p.byID[id] = &subagentRow{id: id, desc: desc, start: now} p.order = append(p.order, id) } // update folds a progress event into the row for id, refreshing its activity and // token estimate. A progress for an unknown id (late/out-of-order, arriving // before or without a start) adds the row so no update is lost; now seeds its // start clock in that case. func (p *subagentPanel) update(id, desc, activity string, tokens int, now time.Time) { if p.byID == nil { p.byID = make(map[string]*subagentRow) } row, ok := p.byID[id] if !ok { row = &subagentRow{id: id, desc: desc, start: now} p.byID[id] = row p.order = append(p.order, id) } if activity != "" { row.activity = activity } if desc != "" { row.desc = desc } row.tokens = tokens } // appendOutput accumulates a forwarded text delta into the row for id, so the // expanded view can show the sub-agent's running output. Deltas for an unknown id // are ignored (the row's start/end brackets its output; nothing to attach to). func (p *subagentPanel) appendOutput(id, delta string) { if delta == "" { return } if row, ok := p.byID[id]; ok { row.output += delta } } // remove drops the row for id (the task's toolEndMsg). It is a no-op when id is // absent, so an end without a matching start — or a duplicate end — is safe. // The selection is clamped to the shrunken order so the cursor never dangles past // the end; removing the last row clears the selection entirely. func (p *subagentPanel) remove(id string) { if _, ok := p.byID[id]; !ok { return } delete(p.byID, id) for i, v := range p.order { if v == id { p.order = append(p.order[:i], p.order[i+1:]...) break } } if len(p.order) == 0 { p.clearSelection() return } if p.selected >= len(p.order) { p.selected = len(p.order) - 1 } } // active reports the number of live sub-agents (status rows the panel would // render), ignoring any extra rows an expanded row contributes. func (p *subagentPanel) active() int { return len(p.order) } // hasSelection reports whether a row is currently cursored. func (p *subagentPanel) hasSelection() bool { return p.selecting && p.selected >= 0 && p.selected < len(p.order) } // clearSelection drops the cursor and collapses any expansion. func (p *subagentPanel) clearSelection() { p.selecting = false p.selected = 0 p.expanded = false } // selectUp moves the cursor to the previous row. With no current selection the // first press lands on the last (bottom-most) row; moving up collapses any open // expansion so it re-anchors to the newly selected row. func (p *subagentPanel) selectUp() { if len(p.order) == 0 { return } if !p.selecting { p.selecting = true p.selected = len(p.order) - 1 } else if p.selected > 0 { p.selected-- } p.expanded = false } // selectDown moves the cursor to the next row. With no current selection the // first press lands on the first (top-most) row; moving down collapses any open // expansion so it re-anchors to the newly selected row. func (p *subagentPanel) selectDown() { if len(p.order) == 0 { return } if !p.selecting { p.selecting = true p.selected = 0 } else if p.selected < len(p.order)-1 { p.selected++ } p.expanded = false } // toggleExpand flips the expanded state of the selected row. It is a no-op when // nothing is selected. func (p *subagentPanel) toggleExpand() { if p.hasSelection() { p.expanded = !p.expanded } } // expandedID returns the id of the currently expanded row, or "" when no row is // expanded. It lets the model relayout only when a streamed delta lands on the // row whose inline output pane is on screen. func (p *subagentPanel) expandedID() string { if p.expanded && p.hasSelection() { return p.order[p.selected] } return "" } // lineCount reports how many terminal rows the panel occupies at the given width: // one status line per active sub-agent, plus the wrapped output lines when the // selected row is expanded. relayout uses this to reserve exactly the right // height so the transcript never overlaps the panel. func (p subagentPanel) lineCount(width int) int { if len(p.order) == 0 || width <= 0 { return 0 } n := len(p.order) if p.expanded && p.hasSelection() { if row := p.byID[p.order[p.selected]]; row != nil { n += len(p.expandedLines(row, width)) } } return n } // view renders the panel to a string, one status line per active sub-agent in // insertion order, each truncated to width display columns. The selected row is // marked with a leading cursor and, when expanded, its accumulated output is // rendered on the following (indented, wrapped) lines. It returns "" when there // are no active sub-agents or width is non-positive, so an empty panel // contributes zero rows and zero height. now is the reference time elapsed is // measured from (the spinner tick's time) so the clock advances each frame. func (p subagentPanel) view(theme Theme, width int, now time.Time) string { if len(p.order) == 0 || width <= 0 { return "" } lines := make([]string, 0, len(p.order)+1) for i, id := range p.order { row := p.byID[id] if row == nil { continue } cursored := p.selecting && i == p.selected lines = append(lines, TruncateToWidth(row.render(theme, now, cursored), width)) if cursored && p.expanded { for _, out := range p.expandedLines(row, width) { lines = append(lines, theme.System.Render(out)) } } } return strings.Join(lines, "\n") } // expandedLines builds the wrapped, indented, tail-capped output lines shown // under an expanded row. An empty output yields a single placeholder line so the // pane is never blank. Lines are already truncated to width (as plain text); the // caller styles them. func (p subagentPanel) expandedLines(row *subagentRow, width int) []string { const indent = " " out := strings.TrimRight(row.output, "\n") if out == "" { return []string{indent + "(no output yet)"} } var wrapped []string for _, para := range strings.Split(out, "\n") { for _, seg := range wrapToWidth(para, width-len(indent)) { wrapped = append(wrapped, indent+seg) } } if len(wrapped) > maxExpandedLines { wrapped = wrapped[len(wrapped)-maxExpandedLines:] } return wrapped } // wrapToWidth breaks s into segments no wider than width display columns, cutting // on the column boundary (there is no word-aware wrapping here — sub-agent output // is arbitrary text/code). An empty line yields one empty segment so blank lines // in the output are preserved. func wrapToWidth(s string, width int) []string { if width <= 0 { return []string{s} } if s == "" { return []string{""} } var segs []string for s != "" { seg := TruncateToWidth(s, width) if seg == "" { // guard against no forward progress on odd-width runes segs = append(segs, s) break } segs = append(segs, seg) s = s[len(seg):] } return segs } // render builds one status line for a row: "{cursor}⏺ {desc} · {activity} // ({elapsed} · ↓{tokens})". A blank description is omitted (the line leads with // the glyph and activity); a zero token estimate drops the "↓" stat. When // selected, the line leads with a "❯ " cursor and the head takes the accent color // to stand out; otherwise the glyph + head take the spinner color and the // parenthetical stats are dim, mirroring the spinner line. func (r subagentRow) render(theme Theme, now time.Time, selected bool) string { var head strings.Builder head.WriteString("⏺") if r.desc != "" { fmt.Fprintf(&head, " %s ·", r.desc) } fmt.Fprintf(&head, " %s", r.activity) stats := formatElapsed(now.Sub(r.start)) if r.tokens > 0 { stats += " · ↓" + humanizeInt(r.tokens) } headStyle := theme.Spinner cursor := " " if selected { headStyle = theme.Accent cursor = theme.Accent.Render("❯ ") } return cursor + headStyle.Render(head.String()) + " " + theme.System.Render("("+stats+")") }