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 return strings.Join(parts, " ")
105}
106
107func compactResidentTokens(tokens int64) string {
108 switch {
109 case tokens >= 999_500:
110 return fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
111 case tokens >= 1_000:
112 return fmt.Sprintf("%.0fk", float64(tokens)/1_000)
113 default:
114 return fmt.Sprintf("%d", tokens)
115 }
116}
117
118func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
119 if !snapshot.TurnStartedAt.IsZero() {
120 return snapshot.TurnStartedAt
121 }
122 return snapshot.UpdatedAt
123}
124
125func activityTime(snapshot subagents.ResidentSnapshot) time.Time {
126 if !snapshot.ActivityUpdatedAt.IsZero() {
127 return snapshot.ActivityUpdatedAt
128 }
129 return snapshot.UpdatedAt
130}
131
132func formatResidentSubagentActivityAge(started, now time.Time) string {
133 if started.IsZero() {
134 return "-"
135 }
136 if now.IsZero() {
137 now = time.Now()
138 }
139 elapsed := now.Sub(started)
140 if elapsed < 0 {
141 elapsed = 0
142 }
143 seconds := int(elapsed.Seconds())
144 switch {
145 case seconds < 60:
146 return fmt.Sprintf("%ds", seconds)
147 case seconds < 60*60:
148 return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
149 case seconds < 24*60*60:
150 return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
151 default:
152 return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
153 }
154}
155
156func renderResidentSubagentActivityPage(theme tui.Theme, manager *subagents.ResidentManager, spinnerGlyph string, snapshotLimit, width int, now time.Time) ([]string, int) {
157 if manager == nil || snapshotLimit <= 0 {
158 return nil, 0
159 }
160 snapshots, total := manager.ActiveSnapshotPage(snapshotLimit)
161 hidden := total - len(snapshots)
162 if hidden < 0 {
163 hidden = 0
164 }
165 return renderResidentSubagentActivityLines(theme, spinnerGlyph, snapshots, width, now), hidden
166}
167
168func fitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxBottomRows, width int, bottomRows func([]string) int) []string {
169 if len(lines) == 0 && hidden == 0 {
170 return nil
171 }
172 for maxRows := len(lines) + 1; maxRows >= 0; maxRows-- {
173 candidate := limitResidentSubagentActivityLines(theme, lines, hidden, maxRows, width)
174 if maxRows == 0 || bottomRows(candidate) <= maxBottomRows {
175 return candidate
176 }
177 }
178 return nil
179}
180
181func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
182 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
183 return nil
184 }
185 groups := residentSubagentActivityLineGroups(lines)
186 if len(groups) == 0 {
187 return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
188 }
189 if hidden == 0 && len(lines) <= maxRows {
190 return lines
191 }
192
193 if maxRows == 1 {
194 if hidden == 0 && len(groups) == 1 {
195 return []string{groups[0][0]}
196 }
197 return []string{residentSubagentActivityOverflowLine(theme, hidden+len(groups), width)}
198 }
199
200 visibleGroups := len(groups)
201 overflowHidden := hidden
202 if hidden > 0 {
203 if visibleGroups > maxRows-1 {
204 visibleGroups = maxRows - 1
205 }
206 overflowHidden = hidden + len(groups) - visibleGroups
207 } else if visibleGroups > maxRows {
208 visibleGroups = maxRows - 1
209 overflowHidden = len(groups) - visibleGroups
210 }
211 if visibleGroups < 0 {
212 visibleGroups = 0
213 }
214 if visibleGroups > len(groups) {
215 visibleGroups = len(groups)
216 }
217
218 out := make([]string, 0, maxRows)
219 for index := 0; index < visibleGroups && len(out) < maxRows; index++ {
220 group := groups[index]
221 out = append(out, group[0])
222 remainingActivityRows := visibleGroups - index - 1
223 reservedRows := remainingActivityRows
224 if overflowHidden > 0 {
225 reservedRows++
226 }
227 metadataRows := maxRows - len(out) - reservedRows
228 for _, metadataLine := range group[1:] {
229 if metadataRows <= 0 || len(out) >= maxRows {
230 break
231 }
232 out = append(out, metadataLine)
233 metadataRows--
234 }
235 }
236 if overflowHidden > 0 && len(out) < maxRows {
237 out = append(out, residentSubagentActivityOverflowLine(theme, overflowHidden, width))
238 }
239 return out
240}
241
242func residentSubagentActivityLineGroups(lines []string) [][]string {
243 groups := make([][]string, 0, len(lines))
244 for _, line := range lines {
245 if strings.HasPrefix(line, " ") && len(groups) > 0 {
246 groups[len(groups)-1] = append(groups[len(groups)-1], line)
247 continue
248 }
249 groups = append(groups, []string{line})
250 }
251 return groups
252}
253
254func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
255 text := fmt.Sprintf(" … %d more active subagents", hidden)
256 return theme.FGColor(theme.Muted, truncateResidentSubagentIndicator(text, width))
257}
258
259func truncateResidentSubagentIndicator(s string, width int) string {
260 if width <= 0 {
261 return ""
262 }
263 if runewidth.StringWidth(s) <= width {
264 return s
265 }
266 if width <= 3 {
267 return strings.Repeat(".", width)
268 }
269 return runewidth.Truncate(s, width, "...")
270}