detached dafa502

Merge pull request #152 from bnema/feat/responsive-subagent-layout

feat(tui): expand active subagent activity layout
commit
dafa502b2c09f02da9555f2581b32cc134528b9f
author
bnema · · 2026-08-20 14:25 UTC
committer
GitHub · · 2026-08-20 14:25 UTC
merge of
fae6c20 + dd4ecde · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +469 -51
M packages/agent/modes/interactive_rendering.go +15 -14
@@ -744,14 +744,6 @@ func (i *Interactive) redraw() {
744744 // Floating independent views no longer replace the main bottom band: the
745745 // underlying chat, status, and editor keep updating behind the pane.
746746 residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
747- var allResidentSubagentLines []string
748- residentSubagentHidden := 0
749- if !residentViewActive && i.cfg.ResidentManager != nil {
750- const residentIndicatorSnapshotLimit = 8
751- snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
752- allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
753- residentSubagentHidden = total - len(snapshots)
754- }
755747 var workingLines []string
756748 if busyPrefix != "" && !workingWithStatus {
757749 workingLines = []string{" " + busyPrefix}
@@ -852,15 +844,24 @@ func (i *Interactive) redraw() {
852844 var residentSubagentLines []string
853845 var bottom []string
854846 var inputStartRow int
855- if len(allResidentSubagentLines) > 0 {
856- for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
857- candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
858- candidateBottom, _ := composeBottom(candidate)
859- if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
860- residentSubagentLines = candidate
847+ if !residentViewActive && i.cfg.ResidentManager != nil {
848+ residentCapacity := 0
849+ for candidateRows := maxBottomRows; candidateRows > 0; candidateRows-- {
850+ candidateBottom, _ := composeBottom(make([]string, candidateRows))
851+ if len(candidateBottom) <= maxBottomRows {
852+ residentCapacity = candidateRows
861853 break
862854 }
863855 }
856+ if residentCapacity > 0 {
857+ residentLines, hiddenResidents := renderResidentSubagentActivityPage(i.cfg.Theme, i.cfg.ResidentManager, i.spin.FrameAt(i.clock()), residentCapacity, mainCols, i.clock())
858+ if len(residentLines) > 0 || hiddenResidents > 0 {
859+ residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, residentLines, hiddenResidents, maxBottomRows, mainCols, func(candidate []string) int {
860+ candidateBottom, _ := composeBottom(candidate)
861+ return len(candidateBottom)
862+ })
863+ }
864+ }
864865 }
865866 bottom, inputStartRow = composeBottom(residentSubagentLines)
866867
@@ -744,14 +744,6 @@ func (i *Interactive) redraw() {
744 // Floating independent views no longer replace the main bottom band: the744 // Floating independent views no longer replace the main bottom band: the
745 // underlying chat, status, and editor keep updating behind the pane.745 // underlying chat, status, and editor keep updating behind the pane.
746 residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil746 residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
747- var allResidentSubagentLines []string
748- residentSubagentHidden := 0
749- if !residentViewActive && i.cfg.ResidentManager != nil {
750- const residentIndicatorSnapshotLimit = 8
751- snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
752- allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
753- residentSubagentHidden = total - len(snapshots)
754- }
755 var workingLines []string747 var workingLines []string
756 if busyPrefix != "" && !workingWithStatus {748 if busyPrefix != "" && !workingWithStatus {
757 workingLines = []string{" " + busyPrefix}749 workingLines = []string{" " + busyPrefix}
@@ -852,15 +844,24 @@ func (i *Interactive) redraw() {
852 var residentSubagentLines []string844 var residentSubagentLines []string
853 var bottom []string845 var bottom []string
854 var inputStartRow int846 var inputStartRow int
855- if len(allResidentSubagentLines) > 0 {847+ if !residentViewActive && i.cfg.ResidentManager != nil {
856- for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {848+ residentCapacity := 0
857- candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)849+ for candidateRows := maxBottomRows; candidateRows > 0; candidateRows-- {
858- candidateBottom, _ := composeBottom(candidate)850+ candidateBottom, _ := composeBottom(make([]string, candidateRows))
859- if len(candidateBottom) <= maxBottomRows || maxRows == 0 {851+ if len(candidateBottom) <= maxBottomRows {
860- residentSubagentLines = candidate852+ residentCapacity = candidateRows
861 break853 break
862 }854 }
863 }855 }
856+ if residentCapacity > 0 {
857+ residentLines, hiddenResidents := renderResidentSubagentActivityPage(i.cfg.Theme, i.cfg.ResidentManager, i.spin.FrameAt(i.clock()), residentCapacity, mainCols, i.clock())
858+ if len(residentLines) > 0 || hiddenResidents > 0 {
859+ residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, residentLines, hiddenResidents, maxBottomRows, mainCols, func(candidate []string) int {
860+ candidateBottom, _ := composeBottom(candidate)
861+ return len(candidateBottom)
862+ })
863+ }
864+ }
864 }865 }
865 bottom, inputStartRow = composeBottom(residentSubagentLines)866 bottom, inputStartRow = composeBottom(residentSubagentLines)
866 867
M packages/agent/modes/resident_subagent_indicator.go +124 -28
@@ -15,10 +15,11 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
1515 time.Second,
1616 )
1717
18-// renderResidentSubagentActivityLines renders the bounded, non-sensitive
19-// background work indicator shown directly below the main editor. A queued
20-// child is waiting for a resident scheduler slot; only running children use
21-// the animated glyph.
18+// renderResidentSubagentActivityLines renders the non-sensitive background
19+// work indicator shown directly below the main editor. A queued child is
20+// waiting for a resident scheduler slot; only running children use the
21+// animated glyph. Height limiting happens after this function so every active
22+// child in the provided page can be shown whenever the terminal has room.
2223 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
2324 lines := make([]string, 0, len(snapshots)*2)
2425 for _, snapshot := range snapshots {
@@ -53,16 +54,43 @@ func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, s
5354 plain := fmt.Sprintf("%s %s · %s · activity %s ago · %s", glyph, name, activity,
5455 formatResidentSubagentActivityAge(activityTime(snapshot), now),
5556 formatResidentSubagentActivityAge(turnStartTime(snapshot), now))
56- plain = truncateResidentSubagentIndicator(plain, width-2)
57- if plain == "" {
58- continue
57+ lines = append(lines, renderResidentSubagentActivityGroup(theme, color, plain, residentUsageMetadata(snapshot), width)...)
58+ }
59+ return lines
60+}
61+
62+func renderResidentSubagentActivityGroup(theme tui.Theme, color tui.TerminalColor, activity, metadata string, width int) []string {
63+ if width <= 0 {
64+ return nil
65+ }
66+ const activityIndent = " "
67+ const metadataIndent = " "
68+ activityWidth := runewidth.StringWidth(activity)
69+ metadataWidth := runewidth.StringWidth(metadata)
70+ if metadata != "" && runewidth.StringWidth(activityIndent)+activityWidth+1+metadataWidth <= width {
71+ gap := width - runewidth.StringWidth(activityIndent) - activityWidth - metadataWidth
72+ if gap < 1 {
73+ gap = 1
5974 }
60- lines = append(lines, " "+theme.FGColor(color, plain))
61- if metadata := residentUsageMetadata(snapshot); metadata != "" {
62- metadata = truncateResidentSubagentIndicator(metadata, width-4)
63- if metadata != "" {
64- lines = append(lines, " "+theme.FGColor(theme.Muted, metadata))
65- }
75+ return []string{activityIndent + theme.FGColor(color, activity) + strings.Repeat(" ", gap) + theme.FGColor(theme.Muted, metadata)}
76+ }
77+
78+ activity = truncateResidentSubagentIndicator(activity, width-runewidth.StringWidth(activityIndent))
79+ if activity == "" {
80+ return nil
81+ }
82+ lines := []string{activityIndent + theme.FGColor(color, activity)}
83+ if metadata == "" {
84+ return lines
85+ }
86+ metadataWidthLimit := width - runewidth.StringWidth(metadataIndent)
87+ if metadataWidthLimit <= 0 {
88+ return lines
89+ }
90+ for _, metadataLine := range tui.WrapANSILine(metadata, metadataWidthLimit) {
91+ metadataLine = truncateResidentSubagentIndicator(metadataLine, metadataWidthLimit)
92+ if metadataLine != "" {
93+ lines = append(lines, metadataIndent+theme.FGColor(theme.Muted, metadataLine))
6694 }
6795 }
6896 return lines
@@ -114,34 +142,102 @@ func formatResidentSubagentActivityAge(started, now time.Time) string {
114142 }
115143 }
116144
145+func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) {
146+ if manager == nil || snapshotLimit <= 0 {
147+ return nil, 0
148+ }
149+ snapshots, total := manager.ActiveSnapshotPage(snapshotLimit)
150+ hidden := total - len(snapshots)
151+ if hidden < 0 {
152+ hidden = 0
153+ }
154+ return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden
155+}
156+
157+func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
158+ if len(lines) == 0 && hidden == 0 {
159+ return nil
160+ }
161+ for maxRows := len(lines) + 1; maxRows >= 0; maxRows-- {
162+ candidate := limitResidentSubagentActivityLines(theme, lines, hidden, maxRows, width)
163+ if maxRows == 0 || bottomRows(candidate) <= maxBottomRows {
164+ return candidate
165+ }
166+ }
167+ return nil
168+}
169+
117170 func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
118171 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
119172 return nil
120173 }
121- groups := make([][]string, 0, len(lines))
122- for _, line := range lines {
123- if strings.HasPrefix(line, " ") && len(groups) > 0 {
124- groups[len(groups)-1] = append(groups[len(groups)-1], line)
125- continue
126- }
127- groups = append(groups, []string{line})
174+ groups := residentSubagentActivityLineGroups(lines)
175+ if len(groups) == 0 {
176+ return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
128177 }
129178 if hidden == 0 && len(lines) <= maxRows {
130179 return lines
131180 }
181+
132182 if maxRows == 1 {
133- return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)}
183+ if hidden == 0 && len(groups) == 1 {
184+ return []string{groups[0][0]}
185+ }
186+ return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)}
187+ }
188+
189+ visibleGroups := len(groups)
190+ overflowHidden := hidden
191+ if hidden > 0 {
192+ if visibleGroups > maxRows-1 {
193+ visibleGroups = maxRows - 1
194+ }
195+ overflowHidden = hidden + len(groups) - visibleGroups
196+ } else if visibleGroups > maxRows {
197+ visibleGroups = maxRows - 1
198+ overflowHidden = len(groups) - visibleGroups
134199 }
200+ if visibleGroups < 0 {
201+ visibleGroups = 0
202+ }
203+ if visibleGroups > len(groups) {
204+ visibleGroups = len(groups)
205+ }
206+
135207 out := make([]string, 0, maxRows)
136- visibleGroups := 0
137- for _, group := range groups {
138- if len(out)+len(group) > maxRows-1 {
139- break
208+ for index := 0; index < visibleGroups && len(out) < maxRows; index++ {
209+ group := groups[index]
210+ out = append(out, group[0])
211+ remainingActivityRows := visibleGroups - index - 1
212+ reservedRows := remainingActivityRows
213+ if overflowHidden > 0 {
214+ reservedRows++
140215 }
141- out = append(out, group...)
142- visibleGroups++
216+ metadataRows := maxRows - len(out) - reservedRows
217+ for _, metadataLine := range group[1:] {
218+ if metadataRows <= 0 || len(out) >= maxRows {
219+ break
220+ }
221+ out = append(out, metadataLine)
222+ metadataRows--
223+ }
224+ }
225+ if overflowHidden > 0 && len(out) < maxRows {
226+ out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width))
227+ }
228+ return out
229+}
230+
231+func residentSubagentActivityLineGroups(lines []string) [][]string {
232+ groups := make([][]string, 0, len(lines))
233+ for _, line := range lines {
234+ if strings.HasPrefix(line, " ") && len(groups) > 0 {
235+ groups[len(groups)-1] = append(groups[len(groups)-1], line)
236+ continue
237+ }
238+ groups = append(groups, []string{line})
143239 }
144- return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width))
240+ return groups
145241 }
146242
147243 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
@@ -15,10 +15,11 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
15 time.Second,15 time.Second,
16 )16 )
17 17
18-// renderResidentSubagentActivityLines renders the bounded, non-sensitive18+// renderResidentSubagentActivityLines renders the non-sensitive background
19-// background work indicator shown directly below the main editor. A queued19+// work indicator shown directly below the main editor. A queued child is
20-// child is waiting for a resident scheduler slot; only running children use20+// waiting for a resident scheduler slot; only running children use the
21-// the animated glyph.21+// animated glyph. Height limiting happens after this function so every active
22+// child in the provided page can be shown whenever the terminal has room.
22 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {23 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
23 lines := make([]string, 0, len(snapshots)*2)24 lines := make([]string, 0, len(snapshots)*2)
24 for _, snapshot := range snapshots {25 for _, snapshot := range snapshots {
@@ -53,16 +54,43 @@ func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, s
53 plain := fmt.Sprintf("%s %s · %s · activity %s ago · %s", glyph, name, activity,54 plain := fmt.Sprintf("%s %s · %s · activity %s ago · %s", glyph, name, activity,
54 formatResidentSubagentActivityAge(activityTime(snapshot), now),55 formatResidentSubagentActivityAge(activityTime(snapshot), now),
55 formatResidentSubagentActivityAge(turnStartTime(snapshot), now))56 formatResidentSubagentActivityAge(turnStartTime(snapshot), now))
56- plain = truncateResidentSubagentIndicator(plain, width-2)57+ lines = append(lines, renderResidentSubagentActivityGroup(theme, color, plain, residentUsageMetadata(snapshot), width)...)
57- if plain == "" {58+ }
58- continue59+ return lines
60+}
61+
62+func renderResidentSubagentActivityGroup(theme tui.Theme, color tui.TerminalColor, activity, metadata string, width int) []string {
63+ if width <= 0 {
64+ return nil
65+ }
66+ const activityIndent = " "
67+ const metadataIndent = " "
68+ activityWidth := runewidth.StringWidth(activity)
69+ metadataWidth := runewidth.StringWidth(metadata)
70+ if metadata != "" && runewidth.StringWidth(activityIndent)+activityWidth+1+metadataWidth <= width {
71+ gap := width - runewidth.StringWidth(activityIndent) - activityWidth - metadataWidth
72+ if gap < 1 {
73+ gap = 1
59 }74 }
60- lines = append(lines, " "+theme.FGColor(color, plain))75+ return []string{activityIndent + theme.FGColor(color, activity) + strings.Repeat(" ", gap) + theme.FGColor(theme.Muted, metadata)}
61- if metadata := residentUsageMetadata(snapshot); metadata != "" {76+ }
62- metadata = truncateResidentSubagentIndicator(metadata, width-4)77+
63- if metadata != "" {78+ activity = truncateResidentSubagentIndicator(activity, width-runewidth.StringWidth(activityIndent))
64- lines = append(lines, " "+theme.FGColor(theme.Muted, metadata))79+ if activity == "" {
65- }80+ return nil
81+ }
82+ lines := []string{activityIndent + theme.FGColor(color, activity)}
83+ if metadata == "" {
84+ return lines
85+ }
86+ metadataWidthLimit := width - runewidth.StringWidth(metadataIndent)
87+ if metadataWidthLimit <= 0 {
88+ return lines
89+ }
90+ for _, metadataLine := range tui.WrapANSILine(metadata, metadataWidthLimit) {
91+ metadataLine = truncateResidentSubagentIndicator(metadataLine, metadataWidthLimit)
92+ if metadataLine != "" {
93+ lines = append(lines, metadataIndent+theme.FGColor(theme.Muted, metadataLine))
66 }94 }
67 }95 }
68 return lines96 return lines
@@ -114,34 +142,102 @@ func formatResidentSubagentActivityAge(started, now time.Time) string {
114 }142 }
115 }143 }
116 144
145+func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) {
146+ if manager == nil || snapshotLimit <= 0 {
147+ return nil, 0
148+ }
149+ snapshots, total := manager.ActiveSnapshotPage(snapshotLimit)
150+ hidden := total - len(snapshots)
151+ if hidden < 0 {
152+ hidden = 0
153+ }
154+ return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden
155+}
156+
157+func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
158+ if len(lines) == 0 && hidden == 0 {
159+ return nil
160+ }
161+ for maxRows := len(lines) + 1; maxRows >= 0; maxRows-- {
162+ candidate := limitResidentSubagentActivityLines(theme, lines, hidden, maxRows, width)
163+ if maxRows == 0 || bottomRows(candidate) <= maxBottomRows {
164+ return candidate
165+ }
166+ }
167+ return nil
168+}
169+
117 func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {170 func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
118 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {171 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
119 return nil172 return nil
120 }173 }
121- groups := make([][]string, 0, len(lines))174+ groups := residentSubagentActivityLineGroups(lines)
122- for _, line := range lines {175+ if len(groups) == 0 {
123- if strings.HasPrefix(line, " ") && len(groups) > 0 {176+ return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
124- groups[len(groups)-1] = append(groups[len(groups)-1], line)
125- continue
126- }
127- groups = append(groups, []string{line})
128 }177 }
129 if hidden == 0 && len(lines) <= maxRows {178 if hidden == 0 && len(lines) <= maxRows {
130 return lines179 return lines
131 }180 }
181+
132 if maxRows == 1 {182 if maxRows == 1 {
133- return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)}183+ if hidden == 0 && len(groups) == 1 {
184+ return []string{groups[0][0]}
185+ }
186+ return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)}
187+ }
188+
189+ visibleGroups := len(groups)
190+ overflowHidden := hidden
191+ if hidden > 0 {
192+ if visibleGroups > maxRows-1 {
193+ visibleGroups = maxRows - 1
194+ }
195+ overflowHidden = hidden + len(groups) - visibleGroups
196+ } else if visibleGroups > maxRows {
197+ visibleGroups = maxRows - 1
198+ overflowHidden = len(groups) - visibleGroups
134 }199 }
200+ if visibleGroups < 0 {
201+ visibleGroups = 0
202+ }
203+ if visibleGroups > len(groups) {
204+ visibleGroups = len(groups)
205+ }
206+
135 out := make([]string, 0, maxRows)207 out := make([]string, 0, maxRows)
136- visibleGroups := 0208+ for index := 0; index < visibleGroups && len(out) < maxRows; index++ {
137- for _, group := range groups {209+ group := groups[index]
138- if len(out)+len(group) > maxRows-1 {210+ out = append(out, group[0])
139- break211+ remainingActivityRows := visibleGroups - index - 1
212+ reservedRows := remainingActivityRows
213+ if overflowHidden > 0 {
214+ reservedRows++
140 }215 }
141- out = append(out, group...)216+ metadataRows := maxRows - len(out) - reservedRows
142- visibleGroups++217+ for _, metadataLine := range group[1:] {
218+ if metadataRows <= 0 || len(out) >= maxRows {
219+ break
220+ }
221+ out = append(out, metadataLine)
222+ metadataRows--
223+ }
224+ }
225+ if overflowHidden > 0 && len(out) < maxRows {
226+ out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width))
227+ }
228+ return out
229+}
230+
231+func residentSubagentActivityLineGroups(lines []string) [][]string {
232+ groups := make([][]string, 0, len(lines))
233+ for _, line := range lines {
234+ if strings.HasPrefix(line, " ") && len(groups) > 0 {
235+ groups[len(groups)-1] = append(groups[len(groups)-1], line)
236+ continue
237+ }
238+ groups = append(groups, []string{line})
143 }239 }
144- return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width))240+ return groups
145 }241 }
146 242
147 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {243 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
M packages/agent/modes/resident_subagent_indicator_test.go +311 -7
@@ -1,6 +1,8 @@
11 package modes
22
33 import (
4+ "context"
5+ "fmt"
46 "strings"
57 "testing"
68 "time"
@@ -8,6 +10,7 @@ import (
810 "github.com/bnema/zut/packages/agent/subagents"
911 "github.com/bnema/zut/packages/provider"
1012 "github.com/bnema/zut/packages/tui"
13+ "github.com/mattn/go-runewidth"
1114 )
1215
1316 func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T) {
@@ -27,16 +30,284 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T
2730 }
2831 }
2932
30-func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) {
33+type residentIndicatorTerminal struct {
34+ alertTestTerminal
35+ cols int
36+ rows int
37+}
38+
39+func (t *residentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
40+func (t *residentIndicatorTerminal) Reset() {
41+ t.mu.Lock()
42+ defer t.mu.Unlock()
43+ t.data = nil
44+}
45+
46+func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) {
47+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
48+ return func(ctx context.Context, _ string) error {
49+ <-ctx.Done()
50+ return ctx.Err()
51+ }, nil
52+ })
53+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
54+ spawnRunningResidentIndicatorChildren(t, manager, 10)
55+
56+ term := &residentIndicatorTerminal{cols: 80, rows: 40}
57+ interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
58+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
59+ interactive.rend.Resize(term.cols, term.rows)
60+ term.Reset()
61+ interactive.redraw()
62+
63+ plain := stripANSIBytes(term.String())
64+ for index := 0; index < 10; index++ {
65+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
66+ t.Fatalf("interactive output missing %q:\n%s", want, plain)
67+ }
68+ }
69+
70+ if err := manager.Close(context.Background()); err != nil {
71+ t.Fatal(err)
72+ }
73+ term.Reset()
74+ interactive.redraw()
75+ plain = stripANSIBytes(term.String())
76+ if strings.Contains(plain, "agent-00") {
77+ t.Fatalf("interactive output kept finished subagent activity:\n%s", plain)
78+ }
79+}
80+
81+func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) {
82+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
83+ return func(ctx context.Context, _ string) error {
84+ <-ctx.Done()
85+ return ctx.Err()
86+ }, nil
87+ })
88+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
89+ spawnRunningResidentIndicatorChildren(t, manager, 6)
90+
91+ term := &residentIndicatorTerminal{cols: 80, rows: 8}
92+ interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
93+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
94+ interactive.rend.Resize(term.cols, term.rows)
95+ term.Reset()
96+ interactive.redraw()
97+
98+ plain := stripANSIBytes(term.String())
99+ if count := strings.Count(plain, "agent-"); count != 1 {
100+ t.Fatalf("interactive output showed %d active child rows, want one fitting row under short height:\n%s", count, plain)
101+ }
102+ if !strings.Contains(plain, "… 5 more active subagents") {
103+ t.Fatalf("interactive output did not summarize omitted active children:\n%s", plain)
104+ }
105+}
106+
107+func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) {
108+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
109+ return func(ctx context.Context, _ string) error {
110+ <-ctx.Done()
111+ return ctx.Err()
112+ }, nil
113+ })
114+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
115+ spawnRunningResidentIndicatorChildren(t, manager, 2)
116+
117+ term := &residentIndicatorTerminal{cols: 80, rows: 8}
118+ interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
119+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
120+ interactive.rend.Resize(term.cols, term.rows)
121+ term.Reset()
122+ interactive.redraw()
123+
124+ plain := stripANSIBytes(term.String())
125+ for index := 0; index < 2; index++ {
126+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
127+ t.Fatalf("interactive output missing fitting active child %q:\n%s", want, plain)
128+ }
129+ }
130+ if strings.Contains(plain, "more active subagents") {
131+ t.Fatalf("interactive output summarized fitting active children:\n%s", plain)
132+ }
133+}
134+
135+func spawnRunningResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
136+ t.Helper()
137+ updates := make(chan string, count*2)
138+ manager.SetUpdateObserver(func(childID string) {
139+ select {
140+ case updates <- childID:
141+ default:
142+ }
143+ })
144+ spawnResidentIndicatorChildren(t, manager, count)
145+ defer manager.SetUpdateObserver(nil)
146+
147+ running := make(map[string]struct{}, count)
148+ timer := time.NewTimer(time.Second)
149+ defer timer.Stop()
150+ for len(running) < count {
151+ select {
152+ case childID := <-updates:
153+ snapshot, ok := manager.SnapshotFor(childID)
154+ if ok && snapshot.State == subagents.ResidentRunning {
155+ running[childID] = struct{}{}
156+ }
157+ case <-timer.C:
158+ snapshots, _ := manager.ActiveSnapshotPage(0)
159+ t.Fatalf("running resident children = %d, want %d; active snapshots=%#v", len(running), count, snapshots)
160+ }
161+ }
162+}
163+
164+func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
165+ t.Helper()
166+ for index := 0; index < count; index++ {
167+ spec := subagents.ResidentChildSpec{
168+ ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
169+ Provider: "openai", Model: "gpt-test",
170+ }
171+ if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
172+ t.Fatalf("Spawn(%d): %v", index, err)
173+ }
174+ }
175+}
176+
177+func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
178+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
179+ return func(ctx context.Context, _ string) error {
180+ <-ctx.Done()
181+ return ctx.Err()
182+ }, nil
183+ })
184+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
185+ spawnResidentIndicatorChildren(t, manager, 5)
186+
187+ lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC))
188+ plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
189+ if hidden != 3 {
190+ t.Fatalf("hidden active children = %d, want 3", hidden)
191+ }
192+ if count := strings.Count(plain, "agent-"); count != 2 {
193+ t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain)
194+ }
195+}
196+
197+func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
31198 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
199+ var snapshots []subagents.ResidentSnapshot
200+ for index := 0; index < 12; index++ {
201+ snapshots = append(snapshots, subagents.ResidentSnapshot{
202+ ID: fmt.Sprintf("child-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), State: subagents.ResidentRunning,
203+ TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now,
204+ })
205+ }
206+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", snapshots, 80, now)
207+ plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
208+ for index := 0; index < 12; index++ {
209+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
210+ t.Fatalf("activity lines missing %q:\n%s", want, plain)
211+ }
212+ }
213+}
214+
215+func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t *testing.T) {
216+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
217+ const width = 120
218+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
219+ ID: "running-id", Profile: "審査", State: subagents.ResidentRunning,
220+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
221+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
222+ }}, width, now)
223+ plain := plainResidentIndicatorLines(lines)
224+ if len(plain) != 1 {
225+ t.Fatalf("activity lines = %#v, want one right-aligned row", plain)
226+ }
227+ metadata := "↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k"
228+ if !strings.HasSuffix(plain[0], metadata) {
229+ t.Fatalf("activity line = %q, want metadata suffix %q", plain[0], metadata)
230+ }
231+ if gotWidth := runewidth.StringWidth(plain[0]); gotWidth != width {
232+ t.Fatalf("activity line width = %d, want %d: %q", gotWidth, width, plain[0])
233+ }
234+}
235+
236+func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
237+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
238+ const width = 34
32239 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
33240 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34241 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
35242 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
36- }}, 100, now)
37- got := strings.Join(plainResidentIndicatorLines(lines), "\n")
38- if !strings.Contains(got, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k") {
39- t.Fatalf("activity lines = %q, want usage metadata line", got)
243+ }}, width, now)
244+ plain := plainResidentIndicatorLines(lines)
245+ if len(plain) < 3 {
246+ t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
247+ }
248+ if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], " ") {
249+ t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
250+ }
251+ joinedMetadata := strings.Join(plain[1:], " ")
252+ for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
253+ if !strings.Contains(joinedMetadata, want) {
254+ t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
255+ }
256+ }
257+ for _, line := range plain {
258+ if gotWidth := runewidth.StringWidth(line); gotWidth > width {
259+ t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
260+ }
261+ }
262+}
263+
264+func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
265+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
266+ const width = 34
267+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
268+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
269+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
270+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
271+ }}, width, now)
272+ if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
273+ t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
274+ }
275+
276+ got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
277+ return len(candidate)
278+ }))
279+ if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
280+ t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
281+ }
282+}
283+
284+func TestRenderResidentSubagentActivityLinesOmitsMetadataWhenIndentDoesNotFit(t *testing.T) {
285+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
286+ snapshot := subagents.ResidentSnapshot{
287+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
288+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
289+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
290+ }
291+ for _, width := range []int{3, 4} {
292+ t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
293+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{snapshot}, width, now)
294+ plain := plainResidentIndicatorLines(lines)
295+ wantActivity := " " + strings.Repeat(".", width-runewidth.StringWidth(" "))
296+ if len(plain) != 1 || plain[0] != wantActivity {
297+ t.Fatalf("activity lines = %#v, want only activity row %#v", plain, []string{wantActivity})
298+ }
299+ if strings.Contains(strings.Join(plain, "\n"), "↑84k") {
300+ t.Fatalf("activity lines include metadata despite unavailable indent: %#v", plain)
301+ }
302+ if gotWidth := runewidth.StringWidth(plain[0]); gotWidth > width {
303+ t.Fatalf("activity line width = %d, want <= %d: %q", gotWidth, width, plain[0])
304+ }
305+
306+ limited := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width))
307+ if len(limited) != 1 || limited[0] != plain[0] || strings.Contains(limited[0], "more active") {
308+ t.Fatalf("limited activity lines = %#v, want preserved activity row %#v without overflow", limited, plain)
309+ }
310+ })
40311 }
41312 }
42313
@@ -65,6 +336,22 @@ func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testin
65336 }
66337 }
67338
339+func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
340+ lines := []string{" one", " two", " three", " four"}
341+ bottomRows := func(candidate []string) int { return 2 + len(candidate) }
342+
343+ allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
344+ if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
345+ t.Fatalf("fit lines = %#v, want all activity rows", allFit)
346+ }
347+
348+ compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
349+ wantCompact := []string{" one", " … 3 more active subagents"}
350+ if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
351+ t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
352+ }
353+}
354+
68355 func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
69356 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
70357 want := []string{"one", " … 2 more active subagents"}
@@ -73,10 +360,27 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
73360 }
74361 }
75362
76-func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) {
363+func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
364+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
365+ want := []string{" … 3 more active subagents"}
366+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
367+ t.Fatalf("limited lines = %#v, want %#v", got, want)
368+ }
369+}
370+
371+func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
77372 lines := []string{" agent one", " usage one", " agent two", " usage two"}
78373 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
79- want := []string{" agent one", " usage one", " … 1 more active subagents"}
374+ want := []string{" agent one", " usage one", " agent two"}
375+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
376+ t.Fatalf("limited lines = %#v, want %#v", got, want)
377+ }
378+}
379+
380+func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
381+ lines := []string{" agent one", " usage one", " usage continued", " agent two", " usage two"}
382+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
383+ want := []string{" agent one", " … 2 more active subagents"}
80384 if strings.Join(got, "\n") != strings.Join(want, "\n") {
81385 t.Fatalf("limited lines = %#v, want %#v", got, want)
82386 }
@@ -1,6 +1,8 @@
1 package modes1 package modes
2 2
3 import (3 import (
4+ "context"
5+ "fmt"
4 "strings"6 "strings"
5 "testing"7 "testing"
6 "time"8 "time"
@@ -8,6 +10,7 @@ import (
8 "github.com/bnema/zut/packages/agent/subagents"10 "github.com/bnema/zut/packages/agent/subagents"
9 "github.com/bnema/zut/packages/provider"11 "github.com/bnema/zut/packages/provider"
10 "github.com/bnema/zut/packages/tui"12 "github.com/bnema/zut/packages/tui"
13+ "github.com/mattn/go-runewidth"
11 )14 )
12 15
13 func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T) {16 func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T) {
@@ -27,16 +30,284 @@ func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T
27 }30 }
28 }31 }
29 32
30-func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) {33+type residentIndicatorTerminal struct {
34+ alertTestTerminal
35+ cols int
36+ rows int
37+}
38+
39+func (t *residentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
40+func (t *residentIndicatorTerminal) Reset() {
41+ t.mu.Lock()
42+ defer t.mu.Unlock()
43+ t.data = nil
44+}
45+
46+func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) {
47+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
48+ return func(ctx context.Context, _ string) error {
49+ <-ctx.Done()
50+ return ctx.Err()
51+ }, nil
52+ })
53+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
54+ spawnRunningResidentIndicatorChildren(t, manager, 10)
55+
56+ term := &residentIndicatorTerminal{cols: 80, rows: 40}
57+ interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
58+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
59+ interactive.rend.Resize(term.cols, term.rows)
60+ term.Reset()
61+ interactive.redraw()
62+
63+ plain := stripANSIBytes(term.String())
64+ for index := 0; index < 10; index++ {
65+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
66+ t.Fatalf("interactive output missing %q:\n%s", want, plain)
67+ }
68+ }
69+
70+ if err := manager.Close(context.Background()); err != nil {
71+ t.Fatal(err)
72+ }
73+ term.Reset()
74+ interactive.redraw()
75+ plain = stripANSIBytes(term.String())
76+ if strings.Contains(plain, "agent-00") {
77+ t.Fatalf("interactive output kept finished subagent activity:\n%s", plain)
78+ }
79+}
80+
81+func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) {
82+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
83+ return func(ctx context.Context, _ string) error {
84+ <-ctx.Done()
85+ return ctx.Err()
86+ }, nil
87+ })
88+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
89+ spawnRunningResidentIndicatorChildren(t, manager, 6)
90+
91+ term := &residentIndicatorTerminal{cols: 80, rows: 8}
92+ interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
93+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
94+ interactive.rend.Resize(term.cols, term.rows)
95+ term.Reset()
96+ interactive.redraw()
97+
98+ plain := stripANSIBytes(term.String())
99+ if count := strings.Count(plain, "agent-"); count != 1 {
100+ t.Fatalf("interactive output showed %d active child rows, want one fitting row under short height:\n%s", count, plain)
101+ }
102+ if !strings.Contains(plain, "… 5 more active subagents") {
103+ t.Fatalf("interactive output did not summarize omitted active children:\n%s", plain)
104+ }
105+}
106+
107+func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) {
108+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
109+ return func(ctx context.Context, _ string) error {
110+ <-ctx.Done()
111+ return ctx.Err()
112+ }, nil
113+ })
114+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
115+ spawnRunningResidentIndicatorChildren(t, manager, 2)
116+
117+ term := &residentIndicatorTerminal{cols: 80, rows: 8}
118+ interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
119+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
120+ interactive.rend.Resize(term.cols, term.rows)
121+ term.Reset()
122+ interactive.redraw()
123+
124+ plain := stripANSIBytes(term.String())
125+ for index := 0; index < 2; index++ {
126+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
127+ t.Fatalf("interactive output missing fitting active child %q:\n%s", want, plain)
128+ }
129+ }
130+ if strings.Contains(plain, "more active subagents") {
131+ t.Fatalf("interactive output summarized fitting active children:\n%s", plain)
132+ }
133+}
134+
135+func spawnRunningResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
136+ t.Helper()
137+ updates := make(chan string, count*2)
138+ manager.SetUpdateObserver(func(childID string) {
139+ select {
140+ case updates <- childID:
141+ default:
142+ }
143+ })
144+ spawnResidentIndicatorChildren(t, manager, count)
145+ defer manager.SetUpdateObserver(nil)
146+
147+ running := make(map[string]struct{}, count)
148+ timer := time.NewTimer(time.Second)
149+ defer timer.Stop()
150+ for len(running) < count {
151+ select {
152+ case childID := <-updates:
153+ snapshot, ok := manager.SnapshotFor(childID)
154+ if ok && snapshot.State == subagents.ResidentRunning {
155+ running[childID] = struct{}{}
156+ }
157+ case <-timer.C:
158+ snapshots, _ := manager.ActiveSnapshotPage(0)
159+ t.Fatalf("running resident children = %d, want %d; active snapshots=%#v", len(running), count, snapshots)
160+ }
161+ }
162+}
163+
164+func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
165+ t.Helper()
166+ for index := 0; index < count; index++ {
167+ spec := subagents.ResidentChildSpec{
168+ ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
169+ Provider: "openai", Model: "gpt-test",
170+ }
171+ if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
172+ t.Fatalf("Spawn(%d): %v", index, err)
173+ }
174+ }
175+}
176+
177+func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
178+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
179+ return func(ctx context.Context, _ string) error {
180+ <-ctx.Done()
181+ return ctx.Err()
182+ }, nil
183+ })
184+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
185+ spawnResidentIndicatorChildren(t, manager, 5)
186+
187+ lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC))
188+ plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
189+ if hidden != 3 {
190+ t.Fatalf("hidden active children = %d, want 3", hidden)
191+ }
192+ if count := strings.Count(plain, "agent-"); count != 2 {
193+ t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain)
194+ }
195+}
196+
197+func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
31 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)198 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
199+ var snapshots []subagents.ResidentSnapshot
200+ for index := 0; index < 12; index++ {
201+ snapshots = append(snapshots, subagents.ResidentSnapshot{
202+ ID: fmt.Sprintf("child-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), State: subagents.ResidentRunning,
203+ TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now,
204+ })
205+ }
206+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", snapshots, 80, now)
207+ plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
208+ for index := 0; index < 12; index++ {
209+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
210+ t.Fatalf("activity lines missing %q:\n%s", want, plain)
211+ }
212+ }
213+}
214+
215+func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t *testing.T) {
216+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
217+ const width = 120
218+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
219+ ID: "running-id", Profile: "審査", State: subagents.ResidentRunning,
220+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
221+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
222+ }}, width, now)
223+ plain := plainResidentIndicatorLines(lines)
224+ if len(plain) != 1 {
225+ t.Fatalf("activity lines = %#v, want one right-aligned row", plain)
226+ }
227+ metadata := "↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k"
228+ if !strings.HasSuffix(plain[0], metadata) {
229+ t.Fatalf("activity line = %q, want metadata suffix %q", plain[0], metadata)
230+ }
231+ if gotWidth := runewidth.StringWidth(plain[0]); gotWidth != width {
232+ t.Fatalf("activity line width = %d, want %d: %q", gotWidth, width, plain[0])
233+ }
234+}
235+
236+func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
237+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
238+ const width = 34
32 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{239 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
33 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,240 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},241 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
35 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,242 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
36- }}, 100, now)243+ }}, width, now)
37- got := strings.Join(plainResidentIndicatorLines(lines), "\n")244+ plain := plainResidentIndicatorLines(lines)
38- if !strings.Contains(got, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k") {245+ if len(plain) < 3 {
39- t.Fatalf("activity lines = %q, want usage metadata line", got)246+ t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
247+ }
248+ if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], " ") {
249+ t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
250+ }
251+ joinedMetadata := strings.Join(plain[1:], " ")
252+ for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
253+ if !strings.Contains(joinedMetadata, want) {
254+ t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
255+ }
256+ }
257+ for _, line := range plain {
258+ if gotWidth := runewidth.StringWidth(line); gotWidth > width {
259+ t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
260+ }
261+ }
262+}
263+
264+func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
265+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
266+ const width = 34
267+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
268+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
269+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
270+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
271+ }}, width, now)
272+ if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
273+ t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
274+ }
275+
276+ got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
277+ return len(candidate)
278+ }))
279+ if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
280+ t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
281+ }
282+}
283+
284+func TestRenderResidentSubagentActivityLinesOmitsMetadataWhenIndentDoesNotFit(t *testing.T) {
285+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
286+ snapshot := subagents.ResidentSnapshot{
287+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
288+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
289+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
290+ }
291+ for _, width := range []int{3, 4} {
292+ t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
293+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{snapshot}, width, now)
294+ plain := plainResidentIndicatorLines(lines)
295+ wantActivity := " " + strings.Repeat(".", width-runewidth.StringWidth(" "))
296+ if len(plain) != 1 || plain[0] != wantActivity {
297+ t.Fatalf("activity lines = %#v, want only activity row %#v", plain, []string{wantActivity})
298+ }
299+ if strings.Contains(strings.Join(plain, "\n"), "↑84k") {
300+ t.Fatalf("activity lines include metadata despite unavailable indent: %#v", plain)
301+ }
302+ if gotWidth := runewidth.StringWidth(plain[0]); gotWidth > width {
303+ t.Fatalf("activity line width = %d, want <= %d: %q", gotWidth, width, plain[0])
304+ }
305+
306+ limited := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width))
307+ if len(limited) != 1 || limited[0] != plain[0] || strings.Contains(limited[0], "more active") {
308+ t.Fatalf("limited activity lines = %#v, want preserved activity row %#v without overflow", limited, plain)
309+ }
310+ })
40 }311 }
41 }312 }
42 313
@@ -65,6 +336,22 @@ func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testin
65 }336 }
66 }337 }
67 338
339+func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
340+ lines := []string{" one", " two", " three", " four"}
341+ bottomRows := func(candidate []string) int { return 2 + len(candidate) }
342+
343+ allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
344+ if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
345+ t.Fatalf("fit lines = %#v, want all activity rows", allFit)
346+ }
347+
348+ compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
349+ wantCompact := []string{" one", " … 3 more active subagents"}
350+ if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
351+ t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
352+ }
353+}
354+
68 func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {355 func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
69 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))356 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
70 want := []string{"one", " … 2 more active subagents"}357 want := []string{"one", " … 2 more active subagents"}
@@ -73,10 +360,27 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
73 }360 }
74 }361 }
75 362
76-func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) {363+func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
364+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
365+ want := []string{" … 3 more active subagents"}
366+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
367+ t.Fatalf("limited lines = %#v, want %#v", got, want)
368+ }
369+}
370+
371+func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
77 lines := []string{" agent one", " usage one", " agent two", " usage two"}372 lines := []string{" agent one", " usage one", " agent two", " usage two"}
78 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))373 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
79- want := []string{" agent one", " usage one", " … 1 more active subagents"}374+ want := []string{" agent one", " usage one", " agent two"}
375+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
376+ t.Fatalf("limited lines = %#v, want %#v", got, want)
377+ }
378+}
379+
380+func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
381+ lines := []string{" agent one", " usage one", " usage continued", " agent two", " usage two"}
382+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
383+ want := []string{" agent one", " … 2 more active subagents"}
80 if strings.Join(got, "\n") != strings.Join(want, "\n") {384 if strings.Join(got, "\n") != strings.Join(want, "\n") {
81 t.Fatalf("limited lines = %#v, want %#v", got, want)385 t.Fatalf("limited lines = %#v, want %#v", got, want)
82 }386 }
M packages/agent/subagents/resident_manager.go +5 -2
@@ -729,8 +729,8 @@ func (m *ResidentManager) RecentSnapshotPage(offset, limit int) ([]ResidentSnaps
729729 return result, total
730730 }
731731
732-// ActiveSnapshotPage returns only queued and running children for compact
733-// activity surfaces. It deliberately exposes no prompt, transcript, or path.
732+// ActiveSnapshotPage returns only same-host queued and running children for
733+// compact activity surfaces. It deliberately exposes no prompt, transcript, or path.
734734 func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int) {
735735 if m == nil {
736736 return nil, 0
@@ -757,6 +757,9 @@ func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int
757757 if _, exists := live[id]; exists {
758758 continue
759759 }
760+ if snapshot.OwnedElsewhere {
761+ continue
762+ }
760763 if snapshot.State == ResidentQueued || snapshot.State == ResidentRunning {
761764 result = append(result, snapshot)
762765 }
@@ -729,8 +729,8 @@ func (m *ResidentManager) RecentSnapshotPage(offset, limit int) ([]ResidentSnaps
729 return result, total729 return result, total
730 }730 }
731 731
732-// ActiveSnapshotPage returns only queued and running children for compact732+// ActiveSnapshotPage returns only same-host queued and running children for
733-// activity surfaces. It deliberately exposes no prompt, transcript, or path.733+// compact activity surfaces. It deliberately exposes no prompt, transcript, or path.
734 func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int) {734 func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int) {
735 if m == nil {735 if m == nil {
736 return nil, 0736 return nil, 0
@@ -757,6 +757,9 @@ func (m *ResidentManager) ActiveSnapshotPage(limit int) ([]ResidentSnapshot, int
757 if _, exists := live[id]; exists {757 if _, exists := live[id]; exists {
758 continue758 continue
759 }759 }
760+ if snapshot.OwnedElsewhere {
761+ continue
762+ }
760 if snapshot.State == ResidentQueued || snapshot.State == ResidentRunning {763 if snapshot.State == ResidentQueued || snapshot.State == ResidentRunning {
761 result = append(result, snapshot)764 result = append(result, snapshot)
762 }765 }
M packages/agent/subagents/resident_snapshot_test.go +14 -0
@@ -39,3 +39,17 @@ func TestResidentManagerRecentSnapshotPageOrdersLatestTransitionFirst(t *testing
3939 t.Fatalf("recent page = %#v/%d", page, total)
4040 }
4141 }
42+
43+func TestResidentManagerActiveSnapshotPageSkipsForeignActiveChildren(t *testing.T) {
44+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
45+ manager := &ResidentManager{children: map[string]*ResidentChild{}, recovered: map[string]ResidentSnapshot{
46+ "foreign": {ID: "foreign", State: ResidentRunning, OwnedElsewhere: true, UpdatedAt: now},
47+ "local-newer": {ID: "local-newer", State: ResidentRunning, UpdatedAt: now.Add(-time.Minute)},
48+ "local-older": {ID: "local-older", State: ResidentQueued, UpdatedAt: now.Add(-time.Hour)},
49+ "idle": {ID: "idle", State: ResidentIdle, UpdatedAt: now.Add(time.Minute)},
50+ }, recoveredSpec: map[string]ResidentChildSpec{}, pending: map[string]struct{}{}}
51+ page, total := manager.ActiveSnapshotPage(1)
52+ if total != 2 || len(page) != 1 || page[0].ID != "local-newer" {
53+ t.Fatalf("active page = %#v/%d, want bounded same-host active children", page, total)
54+ }
55+}
@@ -39,3 +39,17 @@ func TestResidentManagerRecentSnapshotPageOrdersLatestTransitionFirst(t *testing
39 t.Fatalf("recent page = %#v/%d", page, total)39 t.Fatalf("recent page = %#v/%d", page, total)
40 }40 }
41 }41 }
42+
43+func TestResidentManagerActiveSnapshotPageSkipsForeignActiveChildren(t *testing.T) {
44+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
45+ manager := &ResidentManager{children: map[string]*ResidentChild{}, recovered: map[string]ResidentSnapshot{
46+ "foreign": {ID: "foreign", State: ResidentRunning, OwnedElsewhere: true, UpdatedAt: now},
47+ "local-newer": {ID: "local-newer", State: ResidentRunning, UpdatedAt: now.Add(-time.Minute)},
48+ "local-older": {ID: "local-older", State: ResidentQueued, UpdatedAt: now.Add(-time.Hour)},
49+ "idle": {ID: "idle", State: ResidentIdle, UpdatedAt: now.Add(time.Minute)},
50+ }, recoveredSpec: map[string]ResidentChildSpec{}, pending: map[string]struct{}{}}
51+ page, total := manager.ActiveSnapshotPage(1)
52+ if total != 2 || len(page) != 1 || page[0].ID != "local-newer" {
53+ t.Fatalf("active page = %#v/%d, want bounded same-host active children", page, total)
54+ }
55+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close