detached 9f5e8fd

feat(tui): expand active subagent activity layout

commit
9f5e8fd05f36deb77fb654a1066663a44c9f84ec
author
brice · · 2026-08-20 08:41 UTC
parent
fae6c20
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +196 -29
M packages/agent/modes/interactive_rendering.go +4 -11
@@ -745,12 +745,9 @@ func (i *Interactive) redraw() {
745745 // underlying chat, status, and editor keep updating behind the pane.
746746 residentViewActive := i.residentSubagentsDialog.Active() || i.residentChildSession != nil
747747 var allResidentSubagentLines []string
748- residentSubagentHidden := 0
749748 if !residentViewActive && i.cfg.ResidentManager != nil {
750- const residentIndicatorSnapshotLimit = 8
751- snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
749+ snapshots, _ := i.cfg.ResidentManager.ActiveSnapshotPage(0)
752750 allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
753- residentSubagentHidden = total - len(snapshots)
754751 }
755752 var workingLines []string
756753 if busyPrefix != "" && !workingWithStatus {
@@ -853,14 +850,10 @@ func (i *Interactive) redraw() {
853850 var bottom []string
854851 var inputStartRow int
855852 if len(allResidentSubagentLines) > 0 {
856- for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {
857- candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
853+ residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, 0, maxBottomRows, mainCols, func(candidate []string) int {
858854 candidateBottom, _ := composeBottom(candidate)
859- if len(candidateBottom) <= maxBottomRows || maxRows == 0 {
860- residentSubagentLines = candidate
861- break
862- }
863- }
855+ return len(candidateBottom)
856+ })
864857 }
865858 bottom, inputStartRow = composeBottom(residentSubagentLines)
866859
@@ -745,12 +745,9 @@ func (i *Interactive) redraw() {
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 []string747 var allResidentSubagentLines []string
748- residentSubagentHidden := 0
749 if !residentViewActive && i.cfg.ResidentManager != nil {748 if !residentViewActive && i.cfg.ResidentManager != nil {
750- const residentIndicatorSnapshotLimit = 8749+ snapshots, _ := i.cfg.ResidentManager.ActiveSnapshotPage(0)
751- snapshots, total := i.cfg.ResidentManager.ActiveSnapshotPage(residentIndicatorSnapshotLimit)
752 allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())750 allResidentSubagentLines = renderResidentSubagentActivityLines(i.cfg.Theme, i.spin.FrameAt(i.clock()), snapshots, mainCols, i.clock())
753- residentSubagentHidden = total - len(snapshots)
754 }751 }
755 var workingLines []string752 var workingLines []string
756 if busyPrefix != "" && !workingWithStatus {753 if busyPrefix != "" && !workingWithStatus {
@@ -853,14 +850,10 @@ func (i *Interactive) redraw() {
853 var bottom []string850 var bottom []string
854 var inputStartRow int851 var inputStartRow int
855 if len(allResidentSubagentLines) > 0 {852 if len(allResidentSubagentLines) > 0 {
856- for maxRows := len(allResidentSubagentLines) + 1; maxRows >= 0; maxRows-- {853+ residentSubagentLines = fitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, 0, maxBottomRows, mainCols, func(candidate []string) int {
857- candidate := limitResidentSubagentActivityLines(i.cfg.Theme, allResidentSubagentLines, residentSubagentHidden, maxRows, mainCols)
858 candidateBottom, _ := composeBottom(candidate)854 candidateBottom, _ := composeBottom(candidate)
859- if len(candidateBottom) <= maxBottomRows || maxRows == 0 {855+ return len(candidateBottom)
860- residentSubagentLines = candidate856+ })
861- break
862- }
863- }
864 }857 }
865 bottom, inputStartRow = composeBottom(residentSubagentLines)858 bottom, inputStartRow = composeBottom(residentSubagentLines)
866 859
M packages/agent/modes/resident_subagent_indicator.go +58 -13
@@ -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 all active
22+// same-host children 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,47 @@ 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+ metadata = truncateResidentSubagentIndicator(metadata, width)
89+ if metadata != "" {
90+ lines = append(lines, theme.FGColor(theme.Muted, metadata))
91+ }
92+ return lines
93+ }
94+ for _, metadataLine := range tui.WrapANSILine(metadata, metadataWidthLimit) {
95+ metadataLine = truncateResidentSubagentIndicator(metadataLine, metadataWidthLimit)
96+ if metadataLine != "" {
97+ lines = append(lines, metadataIndent+theme.FGColor(theme.Muted, metadataLine))
6698 }
6799 }
68100 return lines
@@ -114,6 +146,19 @@ func formatResidentSubagentActivityAge(started, now time.Time) string {
114146 }
115147 }
116148
149+func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
150+ if len(lines) == 0 && hidden == 0 {
151+ return nil
152+ }
153+ for maxRows := len(lines) + 1; maxRows >= 0; maxRows-- {
154+ candidate := limitResidentSubagentActivityLines(theme, lines, hidden, maxRows, width)
155+ if maxRows == 0 || bottomRows(candidate) <= maxBottomRows {
156+ return candidate
157+ }
158+ }
159+ return nil
160+}
161+
117162 func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
118163 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
119164 return nil
@@ -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 all active
22+// same-host children 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,47 @@ 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+ metadata = truncateResidentSubagentIndicator(metadata, width)
89+ if metadata != "" {
90+ lines = append(lines, theme.FGColor(theme.Muted, metadata))
91+ }
92+ return lines
93+ }
94+ for _, metadataLine := range tui.WrapANSILine(metadata, metadataWidthLimit) {
95+ metadataLine = truncateResidentSubagentIndicator(metadataLine, metadataWidthLimit)
96+ if metadataLine != "" {
97+ lines = append(lines, metadataIndent+theme.FGColor(theme.Muted, metadataLine))
66 }98 }
67 }99 }
68 return lines100 return lines
@@ -114,6 +146,19 @@ func formatResidentSubagentActivityAge(started, now time.Time) string {
114 }146 }
115 }147 }
116 148
149+func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
150+ if len(lines) == 0 && hidden == 0 {
151+ return nil
152+ }
153+ for maxRows := len(lines) + 1; maxRows >= 0; maxRows-- {
154+ candidate := limitResidentSubagentActivityLines(theme, lines, hidden, maxRows, width)
155+ if maxRows == 0 || bottomRows(candidate) <= maxBottomRows {
156+ return candidate
157+ }
158+ }
159+ return nil
160+}
161+
117 func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {162 func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
118 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {163 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
119 return nil164 return nil
M packages/agent/modes/resident_subagent_indicator_test.go +134 -5
@@ -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,126 @@ 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+ 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+ }
63+
64+ term := &residentIndicatorTerminal{cols: 80, rows: 40}
65+ interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
66+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
67+ interactive.rend.Resize(term.cols, term.rows)
68+ term.Reset()
69+ interactive.redraw()
70+
71+ plain := stripANSIBytes(term.String())
72+ for index := 0; index < 10; index++ {
73+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
74+ t.Fatalf("interactive output missing %q:\n%s", want, plain)
75+ }
76+ }
77+
78+ if err := manager.Close(context.Background()); err != nil {
79+ t.Fatal(err)
80+ }
81+ term.Reset()
82+ interactive.redraw()
83+ plain = stripANSIBytes(term.String())
84+ if strings.Contains(plain, "agent-00") {
85+ t.Fatalf("interactive output kept finished subagent activity:\n%s", plain)
86+ }
87+}
88+
89+func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
3190 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
91+ var snapshots []subagents.ResidentSnapshot
92+ for index := 0; index < 12; index++ {
93+ snapshots = append(snapshots, subagents.ResidentSnapshot{
94+ ID: fmt.Sprintf("child-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), State: subagents.ResidentRunning,
95+ TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now,
96+ })
97+ }
98+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", snapshots, 80, now)
99+ plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
100+ for index := 0; index < 12; index++ {
101+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
102+ t.Fatalf("activity lines missing %q:\n%s", want, plain)
103+ }
104+ }
105+}
106+
107+func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t *testing.T) {
108+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
109+ const width = 120
110+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
111+ ID: "running-id", Profile: "審査", State: subagents.ResidentRunning,
112+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
113+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
114+ }}, width, now)
115+ plain := plainResidentIndicatorLines(lines)
116+ if len(plain) != 1 {
117+ t.Fatalf("activity lines = %#v, want one right-aligned row", plain)
118+ }
119+ metadata := "↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k"
120+ if !strings.HasSuffix(plain[0], metadata) {
121+ t.Fatalf("activity line = %q, want metadata suffix %q", plain[0], metadata)
122+ }
123+ if gotWidth := runewidth.StringWidth(plain[0]); gotWidth != width {
124+ t.Fatalf("activity line width = %d, want %d: %q", gotWidth, width, plain[0])
125+ }
126+}
127+
128+func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
129+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
130+ const width = 34
32131 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
33132 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34133 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
35134 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)
135+ }}, width, now)
136+ plain := plainResidentIndicatorLines(lines)
137+ if len(plain) < 3 {
138+ t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
139+ }
140+ if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], " ") {
141+ t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
142+ }
143+ joinedMetadata := strings.Join(plain[1:], " ")
144+ for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
145+ if !strings.Contains(joinedMetadata, want) {
146+ t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
147+ }
148+ }
149+ for _, line := range plain {
150+ if gotWidth := runewidth.StringWidth(line); gotWidth > width {
151+ t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
152+ }
40153 }
41154 }
42155
@@ -65,6 +178,22 @@ func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testin
65178 }
66179 }
67180
181+func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
182+ lines := []string{" one", " two", " three", " four"}
183+ bottomRows := func(candidate []string) int { return 2 + len(candidate) }
184+
185+ allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
186+ if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
187+ t.Fatalf("fit lines = %#v, want all activity rows", allFit)
188+ }
189+
190+ compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
191+ wantCompact := []string{" one", " … 3 more active subagents"}
192+ if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
193+ t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
194+ }
195+}
196+
68197 func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
69198 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
70199 want := []string{"one", " … 2 more active subagents"}
@@ -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,126 @@ 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+ 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+ }
63+
64+ term := &residentIndicatorTerminal{cols: 80, rows: 40}
65+ interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
66+ interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
67+ interactive.rend.Resize(term.cols, term.rows)
68+ term.Reset()
69+ interactive.redraw()
70+
71+ plain := stripANSIBytes(term.String())
72+ for index := 0; index < 10; index++ {
73+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
74+ t.Fatalf("interactive output missing %q:\n%s", want, plain)
75+ }
76+ }
77+
78+ if err := manager.Close(context.Background()); err != nil {
79+ t.Fatal(err)
80+ }
81+ term.Reset()
82+ interactive.redraw()
83+ plain = stripANSIBytes(term.String())
84+ if strings.Contains(plain, "agent-00") {
85+ t.Fatalf("interactive output kept finished subagent activity:\n%s", plain)
86+ }
87+}
88+
89+func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
31 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)90 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
91+ var snapshots []subagents.ResidentSnapshot
92+ for index := 0; index < 12; index++ {
93+ snapshots = append(snapshots, subagents.ResidentSnapshot{
94+ ID: fmt.Sprintf("child-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), State: subagents.ResidentRunning,
95+ TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now,
96+ })
97+ }
98+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", snapshots, 80, now)
99+ plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
100+ for index := 0; index < 12; index++ {
101+ if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
102+ t.Fatalf("activity lines missing %q:\n%s", want, plain)
103+ }
104+ }
105+}
106+
107+func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t *testing.T) {
108+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
109+ const width = 120
110+ lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
111+ ID: "running-id", Profile: "審査", State: subagents.ResidentRunning,
112+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
113+ ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
114+ }}, width, now)
115+ plain := plainResidentIndicatorLines(lines)
116+ if len(plain) != 1 {
117+ t.Fatalf("activity lines = %#v, want one right-aligned row", plain)
118+ }
119+ metadata := "↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k"
120+ if !strings.HasSuffix(plain[0], metadata) {
121+ t.Fatalf("activity line = %q, want metadata suffix %q", plain[0], metadata)
122+ }
123+ if gotWidth := runewidth.StringWidth(plain[0]); gotWidth != width {
124+ t.Fatalf("activity line width = %d, want %d: %q", gotWidth, width, plain[0])
125+ }
126+}
127+
128+func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
129+ now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
130+ const width = 34
32 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{131 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
33 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,132 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},133 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,134 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
36- }}, 100, now)135+ }}, width, now)
37- got := strings.Join(plainResidentIndicatorLines(lines), "\n")136+ plain := plainResidentIndicatorLines(lines)
38- if !strings.Contains(got, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k") {137+ if len(plain) < 3 {
39- t.Fatalf("activity lines = %q, want usage metadata line", got)138+ t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
139+ }
140+ if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], " ") {
141+ t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
142+ }
143+ joinedMetadata := strings.Join(plain[1:], " ")
144+ for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
145+ if !strings.Contains(joinedMetadata, want) {
146+ t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
147+ }
148+ }
149+ for _, line := range plain {
150+ if gotWidth := runewidth.StringWidth(line); gotWidth > width {
151+ t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
152+ }
40 }153 }
41 }154 }
42 155
@@ -65,6 +178,22 @@ func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testin
65 }178 }
66 }179 }
67 180
181+func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
182+ lines := []string{" one", " two", " three", " four"}
183+ bottomRows := func(candidate []string) int { return 2 + len(candidate) }
184+
185+ allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
186+ if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
187+ t.Fatalf("fit lines = %#v, want all activity rows", allFit)
188+ }
189+
190+ compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
191+ wantCompact := []string{" one", " … 3 more active subagents"}
192+ if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
193+ t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
194+ }
195+}
196+
68 func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {197 func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
69 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))198 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
70 want := []string{"one", " … 2 more active subagents"}199 want := []string{"one", " … 2 more active subagents"}

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