fix(modes): bound subagent dialog metadata
- commit
- 79ced327a63f9dfdac739ce96608b0157a63ff12
- author
- brice · · 2026-08-14 16:15 UTC
- parent
- 4651140
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 43 | 43 | d.total = 0 | |
| 44 | 44 | return | |
| 45 | 45 | } | |
| 46 | - | if limit < 1 { | |
| 47 | - | limit = 1 | |
| 46 | + | if limit < 0 { | |
| 47 | + | limit = 0 | |
| 48 | 48 | } | |
| 49 | 49 | _, total := d.manager.SnapshotPage(0, 0) | |
| 50 | 50 | d.total = total | |
| @@ -54,6 +54,10 @@ func (d *residentSubagentsDialog) refresh(limit int) { | |||
| 54 | 54 | if d.selected < 0 || d.total == 0 { | |
| 55 | 55 | d.selected = 0 | |
| 56 | 56 | } | |
| 57 | + | if limit == 0 { | |
| 58 | + | d.rows = nil | |
| 59 | + | return | |
| 60 | + | } | |
| 57 | 61 | if d.selected < d.offset { | |
| 58 | 62 | d.offset = d.selected | |
| 59 | 63 | } | |
| @@ -94,12 +98,15 @@ func (d *residentSubagentsDialog) Render(theme tui.Theme, width, height int) []s | |||
| 94 | 98 | // Reserve two lines per child so usage metadata never pushes the dialog | |
| 95 | 99 | // beyond its content height. Children without usage simply consume less. | |
| 96 | 100 | 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 | |
| 99 | 103 | } | |
| 100 | 104 | d.refresh(maxRows) | |
| 101 | 105 | lines := []string{" Resident subagents", " Enter: open Esc: close /subagents new <task>: spawn"} | |
| 102 | 106 | if len(d.rows) == 0 { | |
| 107 | + | if d.total > 0 { | |
| 108 | + | return lines | |
| 109 | + | } | |
| 103 | 110 | return append(lines, "", " No resident subagents.") | |
| 104 | 111 | } | |
| 105 | 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 | 125 | lines = append(lines, marker+truncateResidentSubagentIndicator(label, width-2)) | |
| 119 | 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 | 133 | if d.total > len(d.rows) { | |
| @@ -43,8 +43,8 @@ func (d *residentSubagentsDialog) refresh(limit int) { | |||||
| 43 | d.total = 0 | 43 | d.total = 0 | ||
| 44 | return | 44 | return | ||
| 45 | } | 45 | } | ||
| 46 | - | if limit < 1 { | 46 | + | if limit < 0 { |
| 47 | - | limit = 1 | 47 | + | limit = 0 |
| 48 | } | 48 | } | ||
| 49 | _, total := d.manager.SnapshotPage(0, 0) | 49 | _, total := d.manager.SnapshotPage(0, 0) | ||
| 50 | d.total = total | 50 | 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 = 0 | 55 | 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.selected | 62 | 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 dialog | 98 | // 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 state | 100 | maxRows := (height - 3) / 2 // title, hint, and page indicator/empty state | ||
| 97 | - | if maxRows < 1 { | 101 | + | if maxRows < 0 { |
| 98 | - | maxRows = 1 | 102 | + | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| @@ -29,7 +30,7 @@ func TestResidentSubagentListUsesHumanLabelTerminalStateAndRelativeTime(t *testi | |||
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | |
| 32 | - | completed := make(chan struct{}, 1) | |
| 33 | + | completed := make(chan struct{}, 2) | |
| 33 | 34 | manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) { | |
| 34 | 35 | journal.ConfigureUsage(272_000, true) | |
| 35 | 36 | return func(context.Context, string) error { | |
| @@ -39,13 +40,18 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | |||
| 39 | 40 | }) | |
| 40 | 41 | manager.SetCompletionObserver(func(subagents.ResidentCompletion) { completed <- struct{}{} }) | |
| 41 | 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 | - | 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 | + | } | |
| 44 | 48 | } | |
| 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 | + | } | |
| 49 | 55 | } | |
| 50 | 56 | dialog := newResidentSubagentsDialog() | |
| 51 | 57 | dialog.Open(manager) | |
| @@ -53,6 +59,18 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) { | |||
| 53 | 59 | if !strings.Contains(plain, "\n ↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 76.1%/272k") { | |
| 54 | 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 | 76 | 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) { | ||