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 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.
22func renderResidentSubagentActivityLines(theme tui.Theme, spinnerGlyph string, snapshots []subagents.ResidentSnapshot, width int, now time.Time) []string {
23 lines := make([]string, 0, len(snapshots)*2)
24 for _, snapshot := range snapshots {
25 // Foreign journals remain discoverable through /subagents, but their
26 // activity belongs to another zut host and must not occupy this
27 // session's always-visible editor-adjacent indicator.
28 if snapshot.OwnedElsewhere || (snapshot.State != subagents.ResidentQueued && snapshot.State != subagents.ResidentRunning) {
29 continue
30 }
31 name := sanitizeSessionTreeText(snapshot.Profile)
32 if name == "" {
33 name = sanitizeSessionTreeText(snapshot.ID)
34 }
35 if name == "" {
36 name = "subagent"
37 }
38 glyph := "…"
39 color := theme.Muted
40 activity := "queued"
41 if snapshot.State == subagents.ResidentRunning {
42 glyph = strings.TrimSpace(spinnerGlyph)
43 if glyph == "" {
44 glyph = "."
45 }
46 color = theme.Spinner
47 if snapshot.WaitingForModel {
48 activity = residentProviderWaitingSpinner.FrameAt(activityTime(snapshot), now)
49 } else {
50 activity = "running"
51 }
52 }
53 plain := fmt.Sprintf("%s %s · %s · activity %s ago · %s", glyph, name, activity,
54 formatResidentSubagentActivityAge(activityTime(snapshot), now),
55 formatResidentSubagentActivityAge(turnStartTime(snapshot), now))
56 plain = truncateResidentSubagentIndicator(plain, width-2)
57 if plain == "" {
58 continue
59 }
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 }
66 }
67 }
68 return lines
69}
70
71func residentUsageMetadata(snapshot subagents.ResidentSnapshot) string {
72 parts := tui.UsageStatsParts(tui.UsageStatsParams{Usage: snapshot.Usage, Subscription: snapshot.Subscription})
73 if context := tui.ContextUsageText(snapshot.ContextUsed, snapshot.ContextMax); context != "" {
74 parts = append(parts, context)
75 }
76 return strings.Join(parts, " ")
77}
78
79func turnStartTime(snapshot subagents.ResidentSnapshot) time.Time {
80 if !snapshot.TurnStartedAt.IsZero() {
81 return snapshot.TurnStartedAt
82 }
83 return snapshot.UpdatedAt
84}
85
86func activityTime(snapshot subagents.ResidentSnapshot) time.Time {
87 if !snapshot.ActivityUpdatedAt.IsZero() {
88 return snapshot.ActivityUpdatedAt
89 }
90 return snapshot.UpdatedAt
91}
92
93func formatResidentSubagentActivityAge(started, now time.Time) string {
94 if started.IsZero() {
95 return "-"
96 }
97 if now.IsZero() {
98 now = time.Now()
99 }
100 elapsed := now.Sub(started)
101 if elapsed < 0 {
102 elapsed = 0
103 }
104 seconds := int(elapsed.Seconds())
105 switch {
106 case seconds < 60:
107 return fmt.Sprintf("%ds", seconds)
108 case seconds < 60*60:
109 return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
110 case seconds < 24*60*60:
111 return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
112 default:
113 return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
114 }
115}
116
117func limitResidentSubagentActivityLines(theme tui.Theme, lines []string, hidden, maxRows, width int) []string {
118 if maxRows <= 0 || (len(lines) == 0 && hidden == 0) {
119 return nil
120 }
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})
128 }
129 if hidden == 0 && len(lines) <= maxRows {
130 return lines
131 }
132 if maxRows == 1 {
133 return []string{residentSubagentActivityOverflowLine(theme, len(groups)+hidden, width)}
134 }
135 out := make([]string, 0, maxRows)
136 visibleGroups := 0
137 for _, group := range groups {
138 if len(out)+len(group) > maxRows-1 {
139 break
140 }
141 out = append(out, group...)
142 visibleGroups++
143 }
144 return append(out, residentSubagentActivityOverflowLine(theme, hidden+len(groups)-visibleGroups, width))
145}
146
147func residentSubagentActivityOverflowLine(theme tui.Theme, hidden, width int) string {
148 text := fmt.Sprintf(" … %d more active subagents", hidden)
149 return theme.FGColor(theme.Muted, truncateResidentSubagentIndicator(text, width))
150}
151
152func truncateResidentSubagentIndicator(s string, width int) string {
153 if width <= 0 {
154 return ""
155 }
156 if runewidth.StringWidth(s) <= width {
157 return s
158 }
159 if width <= 3 {
160 return strings.Repeat(".", width)
161 }
162 return runewidth.Truncate(s, width, "...")
163}