Files
2026-08-14 23:41:57 +08:00

318 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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+")")
}