detached 778aec9

fix(tui): preserve bounded subagent activity rows

commit
778aec9794aa20a680f65f593ce4abf1593b4e6d
author
brice · · 2026-08-20 09:06 UTC
parent
9f5e8fd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +236 -40
M packages/agent/modes/interactive_rendering.go +18 -10
@@ -744,11 +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- if !residentViewActive && i.cfg.ResidentManager != nil {
749- snapshots, _ := i.cfg.ResidentManager.ActiveSnapshotPage(0)
750- allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
751- }
752747 var workingLines []string
753748 if busyPrefix != "" && !workingWithStatus {
754749 workingLines = []string{" " + busyPrefix}
@@ -849,11 +844,24 @@ func (i *Interactive) redraw() {
849844 var residentSubagentLines []string
850845 var bottom []string
851846 var inputStartRow int
852- if len(allResidentSubagentLines) > 0 {
853- residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, 0, maxBottomRows, mainCols, func(candidate []string) int {
854- candidateBottom, _ := composeBottom(candidate)
855- return len(candidateBottom)
856- })
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
853+ break
854+ }
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+ }
857865 }
858866 bottom, inputStartRow = composeBottom(residentSubagentLines)
859867
@@ -744,11 +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- if !residentViewActive && i.cfg.ResidentManager != nil {
749- snapshots, _ := i.cfg.ResidentManager.ActiveSnapshotPage(0)
750- allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
751- }
752 var workingLines []string747 var workingLines []string
753 if busyPrefix != "" && !workingWithStatus {748 if busyPrefix != "" && !workingWithStatus {
754 workingLines = []string{" " + busyPrefix}749 workingLines = []string{" " + busyPrefix}
@@ -849,11 +844,24 @@ func (i *Interactive) redraw() {
849 var residentSubagentLines []string844 var residentSubagentLines []string
850 var bottom []string845 var bottom []string
851 var inputStartRow int846 var inputStartRow int
852- if len(allResidentSubagentLines) > 0 {847+ if !residentViewActive && i.cfg.ResidentManager != nil {
853- residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, 0, maxBottomRows, mainCols, func(candidate []string) int {848+ residentCapacity := 0
854- candidateBottom, _ := composeBottom(candidate)849+ for candidateRows := maxBottomRows; candidateRows > 0; candidateRows-- {
855- return len(candidateBottom)850+ candidateBottom, _ := composeBottom(make([]string, candidateRows))
856- })851+ if len(candidateBottom) <= maxBottomRows {
852+ residentCapacity = candidateRows
853+ break
854+ }
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+ }
857 }865 }
858 bottom, inputStartRow = composeBottom(residentSubagentLines)866 bottom, inputStartRow = composeBottom(residentSubagentLines)
859 867
M packages/agent/modes/resident_subagent_indicator.go +72 -17
@@ -18,8 +18,8 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
1818 // renderResidentSubagentActivityLines renders the non-sensitive background
1919 // work indicator shown directly below the main editor. A queued child is
2020 // waiting for a resident scheduler slot; only running children use the
21-// animated glyph. Height limiting happens after this function so all active
22-// same-host children can be shown whenever the terminal has room.
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.
2323 func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
2424 lines := make([]string, 0, len(snapshots)*2)
2525 for _, snapshot := range snapshots {
@@ -146,6 +146,18 @@ func formatResidentSubagentActivityAge(started, now time.Time) string {
146146 }
147147 }
148148
149+func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) {
150+ if manager == nil || snapshotLimit <= 0 {
151+ return nil, 0
152+ }
153+ snapshots, total := manager.ActiveSnapshotPage(snapshotLimit)
154+ hidden := total - len(snapshots)
155+ if hidden < 0 {
156+ hidden = 0
157+ }
158+ return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden
159+}
160+
149161 func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
150162 if len(lines) == 0 && hidden == 0 {
151163 return nil
@@ -163,30 +175,73 @@ func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden,
163175 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
164176 return nil
165177 }
166- groups := make([][]string, 0, len(lines))
167- for _, line := range lines {
168- if strings.HasPrefix(line, " ") && len(groups) > 0 {
169- groups[len(groups)-1] = append(groups[len(groups)-1], line)
170- continue
171- }
172- groups = append(groups, []string{line})
178+ groups := residentSubagentActivityLineGroups(lines)
179+ if len(groups) == 0 {
180+ return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
173181 }
174182 if hidden == 0 && len(lines) <= maxRows {
175183 return lines
176184 }
185+
177186 if maxRows == 1 {
178- return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)}
187+ if hidden == 0 && len(groups) == 1 {
188+ return []string{groups[0][0]}
189+ }
190+ return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)}
191+ }
192+
193+ visibleGroups := len(groups)
194+ overflowHidden := hidden
195+ if hidden > 0 {
196+ if visibleGroups > maxRows-1 {
197+ visibleGroups = maxRows - 1
198+ }
199+ overflowHidden = hidden + len(groups) - visibleGroups
200+ } else if visibleGroups > maxRows {
201+ visibleGroups = maxRows - 1
202+ overflowHidden = len(groups) - visibleGroups
179203 }
204+ if visibleGroups < 0 {
205+ visibleGroups = 0
206+ }
207+ if visibleGroups > len(groups) {
208+ visibleGroups = len(groups)
209+ }
210+
180211 out := make([]string, 0, maxRows)
181- visibleGroups := 0
182- for _, group := range groups {
183- if len(out)+len(group) > maxRows-1 {
184- break
212+ for index := 0; index < visibleGroups && len(out) < maxRows; index++ {
213+ group := groups[index]
214+ out = append(out, group[0])
215+ remainingActivityRows := visibleGroups - index - 1
216+ reservedRows := remainingActivityRows
217+ if overflowHidden > 0 {
218+ reservedRows++
219+ }
220+ metadataRows := maxRows - len(out) - reservedRows
221+ for _, metadataLine := range group[1:] {
222+ if metadataRows <= 0 || len(out) >= maxRows {
223+ break
224+ }
225+ out = append(out, metadataLine)
226+ metadataRows--
227+ }
228+ }
229+ if overflowHidden > 0 && len(out) < maxRows {
230+ out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width))
231+ }
232+ return out
233+}
234+
235+func residentSubagentActivityLineGroups(lines []string) [][]string {
236+ groups := make([][]string, 0, len(lines))
237+ for _, line := range lines {
238+ if strings.HasPrefix(line, " ") && len(groups) > 0 {
239+ groups[len(groups)-1] = append(groups[len(groups)-1], line)
240+ continue
185241 }
186- out = append(out, group...)
187- visibleGroups++
242+ groups = append(groups, []string{line})
188243 }
189- return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width))
244+ return groups
190245 }
191246
192247 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
@@ -18,8 +18,8 @@ var residentProviderWaitingSpinner = tui.NewStringSpinner(
18 // renderResidentSubagentActivityLines renders the non-sensitive background18 // renderResidentSubagentActivityLines renders the non-sensitive background
19 // work indicator shown directly below the main editor. A queued child is19 // work indicator shown directly below the main editor. A queued child is
20 // waiting for a resident scheduler slot; only running children use the20 // waiting for a resident scheduler slot; only running children use the
21-// animated glyph. Height limiting happens after this function so all active21+// animated glyph. Height limiting happens after this function so every active
22-// same-host children can be shown whenever the terminal has room.22+// child in the provided page can be shown whenever the terminal has room.
23 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 {
24 lines := make([]string, 0, len(snapshots)*2)24 lines := make([]string, 0, len(snapshots)*2)
25 for _, snapshot := range snapshots {25 for _, snapshot := range snapshots {
@@ -146,6 +146,18 @@ func formatResidentSubagentActivityAge(started, now time.Time) string {
146 }146 }
147 }147 }
148 148
149+func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) {
150+ if manager == nil || snapshotLimit <= 0 {
151+ return nil, 0
152+ }
153+ snapshots, total := manager.ActiveSnapshotPage(snapshotLimit)
154+ hidden := total - len(snapshots)
155+ if hidden < 0 {
156+ hidden = 0
157+ }
158+ return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden
159+}
160+
149 func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {161 func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
150 if len(lines) == 0 && hidden == 0 {162 if len(lines) == 0 && hidden == 0 {
151 return nil163 return nil
@@ -163,30 +175,73 @@ func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden,
163 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {175 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
164 return nil176 return nil
165 }177 }
166- groups := make([][]string, 0, len(lines))178+ groups := residentSubagentActivityLineGroups(lines)
167- for _, line := range lines {179+ if len(groups) == 0 {
168- if strings.HasPrefix(line, " ") && len(groups) > 0 {180+ return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
169- groups[len(groups)-1] = append(groups[len(groups)-1], line)
170- continue
171- }
172- groups = append(groups, []string{line})
173 }181 }
174 if hidden == 0 && len(lines) <= maxRows {182 if hidden == 0 && len(lines) <= maxRows {
175 return lines183 return lines
176 }184 }
185+
177 if maxRows == 1 {186 if maxRows == 1 {
178- return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)}187+ if hidden == 0 && len(groups) == 1 {
188+ return []string{groups[0][0]}
189+ }
190+ return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)}
191+ }
192+
193+ visibleGroups := len(groups)
194+ overflowHidden := hidden
195+ if hidden > 0 {
196+ if visibleGroups > maxRows-1 {
197+ visibleGroups = maxRows - 1
198+ }
199+ overflowHidden = hidden + len(groups) - visibleGroups
200+ } else if visibleGroups > maxRows {
201+ visibleGroups = maxRows - 1
202+ overflowHidden = len(groups) - visibleGroups
179 }203 }
204+ if visibleGroups < 0 {
205+ visibleGroups = 0
206+ }
207+ if visibleGroups > len(groups) {
208+ visibleGroups = len(groups)
209+ }
210+
180 out := make([]string, 0, maxRows)211 out := make([]string, 0, maxRows)
181- visibleGroups := 0212+ for index := 0; index < visibleGroups && len(out) < maxRows; index++ {
182- for _, group := range groups {213+ group := groups[index]
183- if len(out)+len(group) > maxRows-1 {214+ out = append(out, group[0])
184- break215+ remainingActivityRows := visibleGroups - index - 1
216+ reservedRows := remainingActivityRows
217+ if overflowHidden > 0 {
218+ reservedRows++
219+ }
220+ metadataRows := maxRows - len(out) - reservedRows
221+ for _, metadataLine := range group[1:] {
222+ if metadataRows <= 0 || len(out) >= maxRows {
223+ break
224+ }
225+ out = append(out, metadataLine)
226+ metadataRows--
227+ }
228+ }
229+ if overflowHidden > 0 && len(out) < maxRows {
230+ out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width))
231+ }
232+ return out
233+}
234+
235+func residentSubagentActivityLineGroups(lines []string) [][]string {
236+ groups := make([][]string, 0, len(lines))
237+ for _, line := range lines {
238+ if strings.HasPrefix(line, " ") && len(groups) > 0 {
239+ groups[len(groups)-1] = append(groups[len(groups)-1], line)
240+ continue
185 }241 }
186- out = append(out, group...)242+ groups = append(groups, []string{line})
187- visibleGroups++
188 }243 }
189- return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width))244+ return groups
190 }245 }
191 246
192 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {247 func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
M packages/agent/modes/resident_subagent_indicator_test.go +127 -11
@@ -51,15 +51,7 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl
5151 }, nil
5252 })
5353 t.Cleanup(func() { _ = manager.Close(context.Background()) })
54- for index := 0; index < 10; index++ {
55- spec := subagents.ResidentChildSpec{
56- ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
57- Provider: "openai", Model: "gpt-test",
58- }
59- if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
60- t.Fatalf("Spawn(%d): %v", index, err)
61- }
62- }
54+ spawnResidentIndicatorChildren(t, manager, 10)
6355
6456 term := &residentIndicatorTerminal{cols: 80, rows: 40}
6557 interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
@@ -86,6 +78,93 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl
8678 }
8779 }
8880
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+ spawnResidentIndicatorChildren(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+ spawnResidentIndicatorChildren(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 spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
136+ t.Helper()
137+ for index := 0; index < count; index++ {
138+ spec := subagents.ResidentChildSpec{
139+ ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
140+ Provider: "openai", Model: "gpt-test",
141+ }
142+ if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
143+ t.Fatalf("Spawn(%d): %v", index, err)
144+ }
145+ }
146+}
147+
148+func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
149+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
150+ return func(ctx context.Context, _ string) error {
151+ <-ctx.Done()
152+ return ctx.Err()
153+ }, nil
154+ })
155+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
156+ spawnResidentIndicatorChildren(t, manager, 5)
157+
158+ lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC))
159+ plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
160+ if hidden != 3 {
161+ t.Fatalf("hidden active children = %d, want 3", hidden)
162+ }
163+ if count := strings.Count(plain, "agent-"); count != 2 {
164+ t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain)
165+ }
166+}
167+
89168 func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
90169 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
91170 var snapshots []subagents.ResidentSnapshot
@@ -153,6 +232,26 @@ func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t
153232 }
154233 }
155234
235+func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
236+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
237+ const width = 34
238+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
239+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
240+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
241+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
242+ }}, width, now)
243+ if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
244+ t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
245+ }
246+
247+ got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
248+ return len(candidate)
249+ }))
250+ if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
251+ t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
252+ }
253+}
254+
156255 func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
157256 started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
158257 now := started.Add(2 * time.Second)
@@ -202,10 +301,27 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
202301 }
203302 }
204303
205-func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) {
304+func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
305+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
306+ want := []string{" … 3 more active subagents"}
307+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
308+ t.Fatalf("limited lines = %#v, want %#v", got, want)
309+ }
310+}
311+
312+func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
206313 lines := []string{" agent one", " usage one", " agent two", " usage two"}
207314 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
208- want := []string{" agent one", " usage one", " … 1 more active subagents"}
315+ want := []string{" agent one", " usage one", " agent two"}
316+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
317+ t.Fatalf("limited lines = %#v, want %#v", got, want)
318+ }
319+}
320+
321+func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
322+ lines := []string{" agent one", " usage one", " usage continued", " agent two", " usage two"}
323+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
324+ want := []string{" agent one", " … 2 more active subagents"}
209325 if strings.Join(got, "\n") != strings.Join(want, "\n") {
210326 t.Fatalf("limited lines = %#v, want %#v", got, want)
211327 }
@@ -51,15 +51,7 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl
51 }, nil51 }, nil
52 })52 })
53 t.Cleanup(func() { _ = manager.Close(context.Background()) })53 t.Cleanup(func() { _ = manager.Close(context.Background()) })
54- for index := 0; index < 10; index++ {54+ spawnResidentIndicatorChildren(t, manager, 10)
55- spec := subagents.ResidentChildSpec{
56- ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
57- Provider: "openai", Model: "gpt-test",
58- }
59- if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
60- t.Fatalf("Spawn(%d): %v", index, err)
61- }
62- }
63 55
64 term := &residentIndicatorTerminal{cols: 80, rows: 40}56 term := &residentIndicatorTerminal{cols: 80, rows: 40}
65 interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})57 interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
@@ -86,6 +78,93 @@ func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAl
86 }78 }
87 }79 }
88 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+ spawnResidentIndicatorChildren(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+ spawnResidentIndicatorChildren(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 spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
136+ t.Helper()
137+ for index := 0; index < count; index++ {
138+ spec := subagents.ResidentChildSpec{
139+ ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
140+ Provider: "openai", Model: "gpt-test",
141+ }
142+ if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
143+ t.Fatalf("Spawn(%d): %v", index, err)
144+ }
145+ }
146+}
147+
148+func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
149+ manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
150+ return func(ctx context.Context, _ string) error {
151+ <-ctx.Done()
152+ return ctx.Err()
153+ }, nil
154+ })
155+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
156+ spawnResidentIndicatorChildren(t, manager, 5)
157+
158+ lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC))
159+ plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
160+ if hidden != 3 {
161+ t.Fatalf("hidden active children = %d, want 3", hidden)
162+ }
163+ if count := strings.Count(plain, "agent-"); count != 2 {
164+ t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain)
165+ }
166+}
167+
89 func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {168 func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
90 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)169 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
91 var snapshots []subagents.ResidentSnapshot170 var snapshots []subagents.ResidentSnapshot
@@ -153,6 +232,26 @@ func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t
153 }232 }
154 }233 }
155 234
235+func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
236+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
237+ const width = 34
238+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
239+ ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
240+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
241+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
242+ }}, width, now)
243+ if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
244+ t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
245+ }
246+
247+ got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
248+ return len(candidate)
249+ }))
250+ if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
251+ t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
252+ }
253+}
254+
156 func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {255 func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
157 started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)256 started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
158 now := started.Add(2 * time.Second)257 now := started.Add(2 * time.Second)
@@ -202,10 +301,27 @@ func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
202 }301 }
203 }302 }
204 303
205-func TestLimitResidentSubagentActivityLinesKeepsMetadataWithItsAgent(t *testing.T) {304+func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
305+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
306+ want := []string{" … 3 more active subagents"}
307+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
308+ t.Fatalf("limited lines = %#v, want %#v", got, want)
309+ }
310+}
311+
312+func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
206 lines := []string{" agent one", " usage one", " agent two", " usage two"}313 lines := []string{" agent one", " usage one", " agent two", " usage two"}
207 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))314 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
208- want := []string{" agent one", " usage one", " … 1 more active subagents"}315+ want := []string{" agent one", " usage one", " agent two"}
316+ if strings.Join(got, "\n") != strings.Join(want, "\n") {
317+ t.Fatalf("limited lines = %#v, want %#v", got, want)
318+ }
319+}
320+
321+func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
322+ lines := []string{" agent one", " usage one", " usage continued", " agent two", " usage two"}
323+ got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
324+ want := []string{" agent one", " … 2 more active subagents"}
209 if strings.Join(got, "\n") != strings.Join(want, "\n") {325 if strings.Join(got, "\n") != strings.Join(want, "\n") {
210 t.Fatalf("limited lines = %#v, want %#v", got, want)326 t.Fatalf("limited lines = %#v, want %#v", got, want)
211 }327 }
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