first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+317
View File
@@ -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+")")
}