detached 79ced32

fix(modes): bound subagent dialog metadata

commit
79ced327a63f9dfdac739ce96608b0157a63ff12
author
brice · · 2026-08-14 16:15 UTC
parent
4651140
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +40 -12
M packages/agent/modes/resident_subagents_dialog.go +15 -5
@@ -43,8 +43,8 @@ func (d *residentSubagentsDialog) refresh(limit int) {
4343 d.total = 0
4444 return
4545 }
46- if limit < 1 {
47- limit = 1
46+ if limit < 0 {
47+ limit = 0
4848 }
4949 _, total := d.manager.SnapshotPage(0, 0)
5050 d.total = total
@@ -54,6 +54,10 @@ func (d *residentSubagentsDialog) refresh(limit int) {
5454 if d.selected < 0 || d.total == 0 {
5555 d.selected = 0
5656 }
57+ if limit == 0 {
58+ d.rows = nil
59+ return
60+ }
5761 if d.selected < d.offset {
5862 d.offset = d.selected
5963 }
@@ -94,12 +98,15 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s
9498 // Reserve two lines per child so usage metadata never pushes the dialog
9599 // beyond its content height. Children without usage simply consume less.
96100 maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state
97- if maxRows < 1 {
98- maxRows = 1
101+ if maxRows < 0 {
102+ maxRows = 0
99103 }
100104 d.refresh(maxRows)
101105 lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}
102106 if len(d.rows) == 0 {
107+ if d.total > 0 {
108+ return lines
109+ }
103110 return append(lines, "", " No resident subagents.")
104111 }
105112 for index, row := range d.rows {
@@ -117,7 +124,10 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s
117124 }
118125 lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))
119126 if metadata := residentUsageMetadata(row); metadata != "" {
120- lines = append(lines, " "+truncateResidentSubagentIndicator(metadata, width-4))
127+ metadata = truncateResidentSubagentIndicator(metadata, width-4)
128+ if metadata != "" {
129+ lines = append(lines, " "+metadata)
130+ }
121131 }
122132 }
123133 if d.total > len(d.rows) {
@@ -43,8 +43,8 @@ func (d *residentSubagentsDialog) refresh(limit int) {
43 d.total = 043 d.total = 0
44 return44 return
45 }45 }
46- if limit < 1 {46+ if limit < 0 {
47- limit = 147+ limit = 0
48 }48 }
49 _, total := d.manager.SnapshotPage(0, 0)49 _, total := d.manager.SnapshotPage(0, 0)
50 d.total = total50 d.total = total
@@ -54,6 +54,10 @@ func (d *residentSubagentsDialog) refresh(limit int) {
54 if d.selected < 0 || d.total == 0 {54 if d.selected < 0 || d.total == 0 {
55 d.selected = 055 d.selected = 0
56 }56 }
57+ if limit == 0 {
58+ d.rows = nil
59+ return
60+ }
57 if d.selected < d.offset {61 if d.selected < d.offset {
58 d.offset = d.selected62 d.offset = d.selected
59 }63 }
@@ -94,12 +98,15 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s
94 // Reserve two lines per child so usage metadata never pushes the dialog98 // Reserve two lines per child so usage metadata never pushes the dialog
95 // beyond its content height. Children without usage simply consume less.99 // beyond its content height. Children without usage simply consume less.
96 maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state100 maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state
97- if maxRows < 1 {101+ if maxRows < 0 {
98- maxRows = 1102+ maxRows = 0
99 }103 }
100 d.refresh(maxRows)104 d.refresh(maxRows)
101 lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}105 lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"}
102 if len(d.rows) == 0 {106 if len(d.rows) == 0 {
107+ if d.total > 0 {
108+ return lines
109+ }
103 return append(lines, "", " No resident subagents.")110 return append(lines, "", " No resident subagents.")
104 }111 }
105 for index, row := range d.rows {112 for index, row := range d.rows {
@@ -117,7 +124,10 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s
117 }124 }
118 lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))125 lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2))
119 if metadata := residentUsageMetadata(row); metadata != "" {126 if metadata := residentUsageMetadata(row); metadata != "" {
120- lines = append(lines, " "+truncateResidentSubagentIndicator(metadata, width-4))127+ metadata = truncateResidentSubagentIndicator(metadata, width-4)
128+ if metadata != "" {
129+ lines = append(lines, " "+metadata)
130+ }
121 }131 }
122 }132 }
123 if d.total > len(d.rows) {133 if d.total > len(d.rows) {
M packages/agent/modes/resident_subagents_dialog_test.go +25 -7
@@ -2,6 +2,7 @@ package modes
22
33 import (
44 "context"
5+ "fmt"
56 "strings"
67 "testing"
78 "time"
@@ -29,7 +30,7 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi
2930 }
3031
3132 func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
32- completed := make(chan struct{}, 1)
33+ completed := make(chan struct{}, 2)
3334 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
3435 journal.ConfigureUsage(272_000, true)
3536 return func(context.Context, string) error {
@@ -39,13 +40,18 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
3940 })
4041 manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} })
4142 t.Cleanup(func() { _ = manager.Close(context.Background()) })
42- if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "usage-child", SessionID: "session", Profile: "reviewer", Provider: "openai-codex", Model: "gpt-test"}, "task"); err != nil {
43- t.Fatal(err)
43+ for index := range 2 {
44+ id := fmt.Sprintf("usage-child-%d", index)
45+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: id, SessionID: "session-" + id, Profile: "reviewer", Provider: "openai-codex", Model: "gpt-test"}, "task"); err != nil {
46+ t.Fatal(err)
47+ }
4448 }
45- select {
46- case <-completed:
47- case <-time.After(time.Second):
48- t.Fatal("resident child did not complete")
49+ for range 2 {
50+ select {
51+ case <-completed:
52+ case <-time.After(time.Second):
53+ t.Fatal("resident child did not complete")
54+ }
4955 }
5056 dialog := newResidentSubagentsDialog()
5157 dialog.Open(manager)
@@ -53,6 +59,18 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
5359 if !strings.Contains(plain, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 76.1%/272k") {
5460 t.Fatalf("dialog = %q, want usage metadata line", plain)
5561 }
62+ for _, height := range []int{3, 4} {
63+ if lines := dialog.Render(tui.Dark, 100, height); len(lines) > height {
64+ t.Fatalf("height %d rendered %d lines: %q", height, len(lines), plainResidentIndicatorLines(lines))
65+ }
66+ }
67+ for width := 1; width <= 3; width++ {
68+ for _, line := range plainResidentIndicatorLines(dialog.Render(tui.Dark, width, 10)) {
69+ if line == " " {
70+ t.Fatalf("width %d rendered an empty metadata row", width)
71+ }
72+ }
73+ }
5674 }
5775
5876 func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {
@@ -2,6 +2,7 @@ package modes
2 2
3 import (3 import (
4 "context"4 "context"
5+ "fmt"
5 "strings"6 "strings"
6 "testing"7 "testing"
7 "time"8 "time"
@@ -29,7 +30,7 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi
29 }30 }
30 31
31 func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {32 func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
32- completed := make(chan struct{}, 1)33+ completed := make(chan struct{}, 2)
33 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {34 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
34 journal.ConfigureUsage(272_000, true)35 journal.ConfigureUsage(272_000, true)
35 return func(context.Context, string) error {36 return func(context.Context, string) error {
@@ -39,13 +40,18 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
39 })40 })
40 manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} })41 manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} })
41 t.Cleanup(func() { _ = manager.Close(context.Background()) })42 t.Cleanup(func() { _ = manager.Close(context.Background()) })
42- if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "usage-child", SessionID: "session", Profile: "reviewer", Provider: "openai-codex", Model: "gpt-test"}, "task"); err != nil {43+ for index := range 2 {
43- t.Fatal(err)44+ id := fmt.Sprintf("usage-child-%d", index)
45+ if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: id, SessionID: "session-" + id, Profile: "reviewer", Provider: "openai-codex", Model: "gpt-test"}, "task"); err != nil {
46+ t.Fatal(err)
47+ }
44 }48 }
45- select {49+ for range 2 {
46- case <-completed:50+ select {
47- case <-time.After(time.Second):51+ case <-completed:
48- t.Fatal("resident child did not complete")52+ case <-time.After(time.Second):
53+ t.Fatal("resident child did not complete")
54+ }
49 }55 }
50 dialog := newResidentSubagentsDialog()56 dialog := newResidentSubagentsDialog()
51 dialog.Open(manager)57 dialog.Open(manager)
@@ -53,6 +59,18 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
53 if !strings.Contains(plain, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 76.1%/272k") {59 if !strings.Contains(plain, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 76.1%/272k") {
54 t.Fatalf("dialog = %q, want usage metadata line", plain)60 t.Fatalf("dialog = %q, want usage metadata line", plain)
55 }61 }
62+ for _, height := range []int{3, 4} {
63+ if lines := dialog.Render(tui.Dark, 100, height); len(lines) > height {
64+ t.Fatalf("height %d rendered %d lines: %q", height, len(lines), plainResidentIndicatorLines(lines))
65+ }
66+ }
67+ for width := 1; width <= 3; width++ {
68+ for _, line := range plainResidentIndicatorLines(dialog.Render(tui.Dark, width, 10)) {
69+ if line == " " {
70+ t.Fatalf("width %d rendered an empty metadata row", width)
71+ }
72+ }
73+ }
56 }74 }
57 75
58 func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {76 func TestResidentResultStatusIncludesFinalSummary(t *testing.T) {

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