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
13// renderSubagentActivityLines returns one compact, input-adjacent row for each
14// active delegated turn. Terminal and idle workers are intentionally omitted:
15// the /subagents dashboard remains the durable history, while this surface is
16// only a live "is it still moving?" signal.
17func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, width int, now time.Time) []string {
18 lines := make([]string, 0, len(snapshots))
19 for _, snapshot := range snapshots {
20 if !subagentTurnIsActive(snapshot) {
21 continue
22 }
23 line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, width, now)
24 if line != "" {
25 lines = append(lines, line)
26 }
27 }
28 return lines
29}
30
31func subagentTurnIsActive(snapshot subagents.AgentSnapshot) bool {
32 switch snapshot.ProcessState {
33 case subagents.ProcessPending, subagents.ProcessStarting, subagents.ProcessAlive:
34 default:
35 // A detached worker has no confirmed live connection. Keep it in the
36 // dashboard, rather than presenting an endlessly animated row beside
37 // the input as if it were still making progress.
38 return false
39 }
40
41 switch snapshot.TurnState {
42 case subagents.TurnSucceeded, subagents.TurnFailed, subagents.TurnCanceled:
43 return false
44 }
45 switch snapshot.Status {
46 case subagents.StatusPending:
47 return true
48 case subagents.StatusRunning:
49 default:
50 return false
51 }
52
53 switch snapshot.TurnState {
54 case subagents.TurnQueued, subagents.TurnRunning, subagents.TurnCanceling:
55 return true
56 }
57
58 // The worker enters TurnIdle while it waits for a follow-up prompt. Keep a
59 // brief "starting" phase visible, but remove the row as soon as it reports
60 // ordinary idle so an already-completed task never looks active.
61 return strings.TrimSpace(snapshot.Activity) != "" && strings.TrimSpace(snapshot.Activity) != "idle"
62}
63
64func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, width int, now time.Time) string {
65 name := sanitizeSubagentIndicatorText(snapshot.Subagent)
66 if name == "" {
67 name = sanitizeSubagentIndicatorText(snapshot.ID)
68 }
69 if name == "" {
70 name = "subagent"
71 }
72 spinnerGlyph = strings.TrimSpace(spinnerGlyph)
73 if spinnerGlyph == "" {
74 spinnerGlyph = "."
75 }
76 activity := compactSubagentActivity(snapshot)
77 age := formatSubagentActivityAge(snapshot, now)
78 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
79 if plain == "" {
80 return ""
81 }
82
83 switch layout {
84 case subagentActivityFull:
85 parts := strings.SplitN(plain, " · ", 3)
86 if len(parts) != 3 {
87 return " " + th.FGColor(th.Muted, plain)
88 }
89 name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
90 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
91 th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · ") +
92 th.FGColor(th.FG, parts[1]) + th.FGColor(th.Muted, " · "+parts[2])
93 case subagentActivityNameAge:
94 parts := strings.SplitN(plain, " · ", 2)
95 if len(parts) != 2 {
96 return " " + th.FGColor(th.Muted, plain)
97 }
98 name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
99 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
100 th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1])
101 case subagentActivityCompact:
102 prefix := spinnerGlyph + " "
103 suffix := " " + age
104 if strings.HasPrefix(plain, prefix) && strings.HasSuffix(plain, suffix) {
105 name = strings.TrimSuffix(strings.TrimPrefix(plain, prefix), suffix)
106 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
107 th.FGColor(th.Assistant, name) + " " + th.FGColor(th.Muted, age)
108 }
109 }
110 return " " + th.FGColor(th.Muted, plain)
111}
112
113type subagentActivityLayout uint8
114
115const (
116 subagentActivityFull subagentActivityLayout = iota
117 subagentActivityNameAge
118 subagentActivityCompact
119)
120
121func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) {
122 spinnerGlyph = strings.TrimSpace(spinnerGlyph)
123 if spinnerGlyph == "" {
124 spinnerGlyph = "."
125 }
126 if width <= 0 {
127 return "", subagentActivityCompact
128 }
129
130 full := spinnerGlyph + " " + name + " · " + activity + " · " + age
131 if runewidth.StringWidth(full) <= width {
132 return full, subagentActivityFull
133 }
134
135 nameAge := spinnerGlyph + " " + name + " · " + age
136 if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 {
137 activity = truncateSubagentIndicatorText(activity, activityWidth)
138 return spinnerGlyph + " " + name + " · " + activity + " · " + age, subagentActivityFull
139 }
140 if runewidth.StringWidth(nameAge) <= width {
141 return nameAge, subagentActivityNameAge
142 }
143
144 prefix := spinnerGlyph + " "
145 suffix := " " + age
146 nameWidth := width - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix)
147 if nameWidth >= 1 {
148 return prefix + truncateSubagentIndicatorText(name, nameWidth) + suffix, subagentActivityCompact
149 }
150 if runewidth.StringWidth(age) <= width {
151 return age, subagentActivityCompact
152 }
153 return truncateSubagentIndicatorText(spinnerGlyph, width), subagentActivityCompact
154}
155
156func compactSubagentActivity(snapshot subagents.AgentSnapshot) string {
157 activity := sanitizeSubagentIndicatorText(snapshot.Activity)
158 // Heartbeats report "idle" even while a delegated turn is still running.
159 // Keep the row meaningful in that interval instead of contradicting its
160 // spinner and turn state.
161 if activity != "" && (activity != "idle" || snapshot.TurnState != subagents.TurnRunning) {
162 return activity
163 }
164 switch snapshot.TurnState {
165 case subagents.TurnQueued:
166 return "queued"
167 case subagents.TurnCanceling:
168 return "cancelling"
169 default:
170 return "working"
171 }
172}
173
174func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) string {
175 last := snapshot.LastActivity
176 if last.IsZero() {
177 last = snapshot.UpdatedAt
178 }
179 if last.IsZero() {
180 last = snapshot.Started
181 }
182 if last.IsZero() {
183 return "-"
184 }
185 if now.IsZero() {
186 now = time.Now()
187 }
188 elapsed := now.Sub(last)
189 if elapsed < 0 {
190 elapsed = 0
191 }
192 seconds := int(elapsed.Seconds())
193 switch {
194 case seconds < 60:
195 return fmt.Sprintf("%ds", seconds)
196 case seconds < 60*60:
197 return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
198 case seconds < 24*60*60:
199 minutes := (seconds % (60 * 60)) / 60
200 return fmt.Sprintf("%dh%02dm", seconds/(60*60), minutes)
201 default:
202 hours := (seconds % (24 * 60 * 60)) / (60 * 60)
203 return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), hours)
204 }
205}
206
207func truncateSubagentIndicatorText(s string, width int) string {
208 if width <= 0 {
209 return ""
210 }
211 if runewidth.StringWidth(s) <= width {
212 return s
213 }
214 if width <= 3 {
215 return strings.Repeat(".", width)
216 }
217 return runewidth.Truncate(s, width, "...")
218}
219
220// sanitizeSubagentIndicatorText keeps worker-derived labels on one safe
221// terminal row before they are measured or styled. A worker's profile, id, or
222// tool activity is data, never terminal control output.
223func sanitizeSubagentIndicatorText(s string) string {
224 return sanitizeSessionTreeText(s)
225}
226
227// limitSubagentActivityLines reserves the input's terminal rows before adding
228// activity rows. When the frame has room for only part of the live set, retain
229// the earliest rows and make the omitted count explicit in one final row.
230func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string {
231 if maxRows <= 0 || len(lines) == 0 {
232 return nil
233 }
234 if len(lines) <= maxRows {
235 return lines
236 }
237 if maxRows == 1 {
238 return []string{subagentActivityOverflowLine(th, len(lines), width)}
239 }
240 out := append([]string(nil), lines[:maxRows-1]...)
241 return append(out, subagentActivityOverflowLine(th, len(lines)-len(out), width))
242}
243
244func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string {
245 text := fmt.Sprintf(" … %d more active subagents", hidden)
246 return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width))
247}
248
249// activeSubagentActivitySnapshots builds the compact state needed by the
250// input indicator without copying transcript buffers. It mirrors the
251// supervisor's live-session filtering while deliberately omitting completed
252// workers, which belong in the dashboard and completion notification instead.
253func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapshot {
254 supervisor := i.cfg.Supervisor
255 if supervisor == nil {
256 return nil
257 }
258
259 activeSession := supervisor.ActiveSession()
260 agents := supervisor.List()
261 out := make([]subagents.AgentSnapshot, 0, len(agents))
262 for _, agent := range agents {
263 if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
264 continue
265 }
266 snapshot := subagents.AgentSnapshot{
267 ID: agent.ID,
268 Status: agent.Status(),
269 ProcessState: agent.ProcessState(),
270 TurnState: agent.TurnState(),
271 Activity: agent.Activity(),
272 Started: agent.Started,
273 UpdatedAt: agent.UpdatedAt(),
274 LastActivity: agent.LastActivity(),
275 Subagent: agent.Subagent,
276 }
277 if subagentTurnIsActive(snapshot) {
278 out = append(out, snapshot)
279 }
280 }
281 return out
282}