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