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 turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
108 if !snapshot.TurnStartedAt.IsZero() {
109 return snapshot.TurnStartedAt
110 }
111 return snapshot.UpdatedAt
112}
113
114func activityTime(snapshot subagents.ResidentSnapshot) time.Time {
115 if !snapshot.ActivityUpdatedAt.IsZero() {
116 return snapshot.ActivityUpdatedAt
117 }
118 return snapshot.UpdatedAt
119}
120
121func formatResidentSubagentActivityAge(started, now time.Time) string {
122 if started.IsZero() {
123 return "-"
124 }
125 if now.IsZero() {
126 now = time.Now()
127 }
128 elapsed := now.Sub(started)
129 if elapsed < 0 {
130 elapsed = 0
131 }
132 seconds := int(elapsed.Seconds())
133 switch {
134 case seconds < 60:
135 return fmt.Sprintf("%ds", seconds)
136 case seconds < 60*60:
137 return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
138 case seconds < 24*60*60:
139 return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
140 default:
141 return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
142 }
143}
144
145func 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
157func 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
170func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
171 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
172 return nil
173 }
174 groups := residentSubagentActivityLineGroups(lines)
175 if len(groups) == 0 {
176 return []string{residentSubagentActivityOverflowLine(theme, hidden, width)}
177 }
178 if hidden == 0 && len(lines) <= maxRows {
179 return lines
180 }
181
182 if maxRows == 1 {
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
199 }
200 if visibleGroups < 0 {
201 visibleGroups = 0
202 }
203 if visibleGroups > len(groups) {
204 visibleGroups = len(groups)
205 }
206
207 out := make([]string, 0, maxRows)
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++
215 }
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
231func 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})
239 }
240 return groups
241}
242
243func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
244 text := fmt.Sprintf(" … %d more active subagents", hidden)
245 return theme.FGColor(theme.Muted, truncateResidentSubagentIndicator(text, width))
246}
247
248func truncateResidentSubagentIndicator(s string, width int) string {
249 if width <= 0 {
250 return ""
251 }
252 if runewidth.StringWidth(s) <= width {
253 return s
254 }
255 if width <= 3 {
256 return strings.Repeat(".", width)
257 }
258 return runewidth.Truncate(s, width, "...")
259}