1package modes
2
3import (
4 "fmt"
5 "strings"
6 "time"
7
8 "github.com/bnema/zut/packages/agent/subagents"
9 "github.com/bnema/zut/packages/tui"
10 "github.com/mattn/go-runewidth"
11)
12
13var residentProviderWaitingSpinner = tui.NewStringSpinner(
14 []string{"Zzzz", "zZzz", "zzZz", "zzzZ"},
15 time.Second,
16)
17
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.
23func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
24 lines := make([]string, 0, len(snapshots)*2)
25 for _, snapshot := range snapshots {
26 // Foreign journals remain discoverable through /subagents, but their
27 // activity belongs to another zut host and must not occupy this
28 // session's always-visible editor-adjacent indicator.
29 if snapshot.OwnedElsewhere || (snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning) {
30 continue
31 }
32 name := sanitizeSessionTreeText(snapshot.Profile)
33 if name == "" {
34 name = sanitizeSessionTreeText(snapshot.ID)
35 }
36 if name == "" {
37 name = "subagent"
38 }
39 glyph := "…"
40 color := theme.Muted
41 activity := "queued"
42 if snapshot.State == subagents.ResidentRunning {
43 glyph = strings.TrimSpace(spinnerGlyph)
44 if glyph == "" {
45 glyph = "."
46 }
47 color = theme.Spinner
48 if snapshot.WaitingForModel {
49 activity = residentProviderWaitingSpinner.FrameAt(activityTime(snapshot), now)
50 } else {
51 activity = "running"
52 }
53 }
54 plain := fmt.Sprintf("%s %s · %s · activity %s ago · %s", glyph, name, activity,
55 formatResidentSubagentActivityAge(activityTime(snapshot), now),
56 formatResidentSubagentActivityAge(turnStartTime(snapshot), now))
57 lines = append(lines, renderResidentSubagentActivityGroup(theme, color, plain, residentUsageMetadata(snapshot), width)...)
58 }
59 return lines
60}
61
62func 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
74 }
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))
94 }
95 }
96 return lines
97}
98
99func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
100 parts := tui.UsageStatsParts(tui.UsageStatsParams{Usage: snapshot.Usage, Subscription: snapshot.Subscription})
101 if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
102 parts = append(parts, context)
103 }
104 if snapshot.Budget.Limit > 0 {
105 parts = append(parts, fmt.Sprintf("budget:%s %d%%/%s", snapshot.Budget.State, snapshot.Budget.Percent, compactResidentTokens(snapshot.Budget.Limit)))
106 }
107 return strings.Join(parts, " ")
108}
109
110func compactResidentTokens(tokens int64) string {
111 switch {
112 case tokens >= 999_500:
113 return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
114 case tokens >= 1_000:
115 return fmt.Sprintf("%.0fk", float64(tokens)/1_000)
116 default:
117 return fmt.Sprintf("%d", tokens)
118 }
119}
120
121func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
122 if !snapshot.TurnStartedAt.IsZero() {
123 return snapshot.TurnStartedAt
124 }
125 return snapshot.UpdatedAt
126}
127
128func activityTime(snapshot subagents.ResidentSnapshot) time.Time {
129 if !snapshot.ActivityUpdatedAt.IsZero() {
130 return snapshot.ActivityUpdatedAt
131 }
132 return snapshot.UpdatedAt
133}
134
135func formatResidentSubagentActivityAge(started, now time.Time) string {
136 if started.IsZero() {
137 return "-"
138 }
139 if now.IsZero() {
140 now = time.Now()
141 }
142 elapsed := now.Sub(started)
143 if elapsed < 0 {
144 elapsed = 0
145 }
146 seconds := int(elapsed.Seconds())
147 switch {
148 case seconds < 60:
149 return fmt.Sprintf("%ds", seconds)
150 case seconds < 60*60:
151 return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
152 case seconds < 24*60*60:
153 return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
154 default:
155 return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
156 }
157}
158
159func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) {
160 if manager == nil || snapshotLimit <= 0 {
161 return nil, 0
162 }
163 snapshots, total := manager.ActiveSnapshotPage(snapshotLimit)
164 hidden := total - len(snapshots)
165 if hidden < 0 {
166 hidden = 0
167 }
168 return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden
169}
170
171func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
172 if len(lines) == 0 && hidden == 0 {
173 return nil
174 }
175 for maxRows := len(lines) + 1; maxRows >= 0; maxRows-- {
176 candidate := limitResidentSubagentActivityLines(theme, lines, hidden, maxRows, width)
177 if maxRows == 0 || bottomRows(candidate) <= maxBottomRows {
178 return candidate
179 }
180 }
181 return nil
182}
183
184func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
185 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
186 return nil
187 }
188 groups := residentSubagentActivityLineGroups(lines)
189 if len(groups) == 0 {
190 return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
191 }
192 if hidden == 0 && len(lines) <= maxRows {
193 return lines
194 }
195
196 if maxRows == 1 {
197 if hidden == 0 && len(groups) == 1 {
198 return []string{groups[0][0]}
199 }
200 return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)}
201 }
202
203 visibleGroups := len(groups)
204 overflowHidden := hidden
205 if hidden > 0 {
206 if visibleGroups > maxRows-1 {
207 visibleGroups = maxRows - 1
208 }
209 overflowHidden = hidden + len(groups) - visibleGroups
210 } else if visibleGroups > maxRows {
211 visibleGroups = maxRows - 1
212 overflowHidden = len(groups) - visibleGroups
213 }
214 if visibleGroups < 0 {
215 visibleGroups = 0
216 }
217 if visibleGroups > len(groups) {
218 visibleGroups = len(groups)
219 }
220
221 out := make([]string, 0, maxRows)
222 for index := 0; index < visibleGroups && len(out) < maxRows; index++ {
223 group := groups[index]
224 out = append(out, group[0])
225 remainingActivityRows := visibleGroups - index - 1
226 reservedRows := remainingActivityRows
227 if overflowHidden > 0 {
228 reservedRows++
229 }
230 metadataRows := maxRows - len(out) - reservedRows
231 for _, metadataLine := range group[1:] {
232 if metadataRows <= 0 || len(out) >= maxRows {
233 break
234 }
235 out = append(out, metadataLine)
236 metadataRows--
237 }
238 }
239 if overflowHidden > 0 && len(out) < maxRows {
240 out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width))
241 }
242 return out
243}
244
245func residentSubagentActivityLineGroups(lines []string) [][]string {
246 groups := make([][]string, 0, len(lines))
247 for _, line := range lines {
248 if strings.HasPrefix(line, " ") && len(groups) > 0 {
249 groups[len(groups)-1] = append(groups[len(groups)-1], line)
250 continue
251 }
252 groups = append(groups, []string{line})
253 }
254 return groups
255}
256
257func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
258 text := fmt.Sprintf(" … %d more active subagents", hidden)
259 return theme.FGColor(theme.Muted, truncateResidentSubagentIndicator(text, width))
260}
261
262func truncateResidentSubagentIndicator(s string, width int) string {
263 if width <= 0 {
264 return ""
265 }
266 if runewidth.StringWidth(s) <= width {
267 return s
268 }
269 if width <= 3 {
270 return strings.Repeat(".", width)
271 }
272 return runewidth.Truncate(s, width, "...")
273}