first commit
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
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+")")
|
||||
}
|
||||
Reference in New Issue
Block a user