1package modes
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9 "unicode"
10
11 "github.com/bnema/zut/packages/agent/extproto"
12 "github.com/bnema/zut/packages/agent/subagents"
13 "github.com/bnema/zut/packages/tui"
14 "github.com/mattn/go-runewidth"
15)
16
17type sizedSubagentIndicatorTerminal struct {
18 *alertTestTerminal
19 cols int
20 rows int
21}
22
23func (t *sizedSubagentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
24
25func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) {
26 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
27 lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{
28 {
29 ID: "design-123",
30 Subagent: "senior-engineer",
31 Status: subagents.StatusPending,
32 ProcessState: subagents.ProcessPending,
33 TurnState: subagents.TurnQueued,
34 Activity: "queued",
35 LastActivity: now.Add(-4 * time.Second),
36 },
37 {
38 ID: "tests-456",
39 Subagent: "",
40 Status: subagents.StatusRunning,
41 ProcessState: subagents.ProcessAlive,
42 TurnState: subagents.TurnRunning,
43 Activity: "tool: read",
44 LastActivity: now.Add(-72 * time.Second),
45 },
46 {
47 ID: "heartbeat-789",
48 Subagent: "implementer",
49 Status: subagents.StatusRunning,
50 ProcessState: subagents.ProcessAlive,
51 TurnState: subagents.TurnRunning,
52 Activity: "idle",
53 LastActivity: now.Add(-5 * time.Second),
54 },
55 {
56 ID: "idle-789",
57 Subagent: "reviewer",
58 Status: subagents.StatusRunning,
59 ProcessState: subagents.ProcessAlive,
60 TurnState: subagents.TurnIdle,
61 Activity: "idle",
62 LastActivity: now.Add(-time.Second),
63 },
64 {
65 ID: "settled-012",
66 Subagent: "implementer",
67 Status: subagents.StatusPending,
68 ProcessState: subagents.ProcessPending,
69 TurnState: subagents.TurnSucceeded,
70 Activity: "done",
71 LastActivity: now.Add(-time.Second),
72 },
73 {
74 ID: "done-345",
75 Subagent: "implementer",
76 Status: subagents.StatusDone,
77 ProcessState: subagents.ProcessExited,
78 TurnState: subagents.TurnSucceeded,
79 Activity: "done",
80 LastActivity: now.Add(-time.Second),
81 },
82 {
83 ID: "detached-678",
84 Subagent: "reviewer",
85 Status: subagents.StatusRunning,
86 ProcessState: subagents.ProcessDetached,
87 TurnState: subagents.TurnRunning,
88 Activity: "detached",
89 LastActivity: now.Add(-time.Second),
90 },
91 }, 80, now)
92
93 got := plainActivityLines(lines)
94 want := []string{
95 " / senior-engineer · queued · 4s",
96 " / tests-456 · tool: read · 1m12s",
97 " / implementer · working · 5s",
98 }
99 if strings.Join(got, "\n") != strings.Join(want, "\n") {
100 t.Fatalf("activity lines = %#v, want %#v", got, want)
101 }
102}
103
104func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {
105 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
106 const width = 27
107 lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{
108 ID: "review-123",
109 Subagent: "reviewer",
110 Status: subagents.StatusRunning,
111 ProcessState: subagents.ProcessAlive,
112 TurnState: subagents.TurnRunning,
113 Activity: "tool: read an exceptionally long filename",
114 LastActivity: now.Add(-72 * time.Second),
115 }}, width, now)
116
117 if len(lines) != 1 {
118 t.Fatalf("line count = %d, want 1", len(lines))
119 }
120 line := plainActivityLines(lines)[0]
121 if runewidth.StringWidth(line) > width {
122 t.Fatalf("line width = %d, want <= %d: %q", runewidth.StringWidth(line), width, line)
123 }
124 for _, want := range []string{"reviewer", "1m12s"} {
125 if !strings.Contains(line, want) {
126 t.Fatalf("narrow line %q is missing %q", line, want)
127 }
128 }
129}
130
131func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) {
132 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
133 lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{
134 ID: "review-123",
135 Subagent: "reviewer\x1b]52;c;ignored\a",
136 Status: subagents.StatusRunning,
137 ProcessState: subagents.ProcessAlive,
138 TurnState: subagents.TurnRunning,
139 Activity: "tool:\tread\x1b[2J\rfile",
140 LastActivity: now.Add(-time.Second),
141 }}, 80, now)
142 if len(lines) != 1 {
143 t.Fatalf("line count = %d, want 1", len(lines))
144 }
145 plain := plainActivityLines(lines)[0]
146 if strings.IndexFunc(plain, unicode.IsControl) >= 0 {
147 t.Fatalf("indicator retained a control character: %q", plain)
148 }
149 for _, want := range []string{"reviewer", "tool: read file"} {
150 if !strings.Contains(plain, want) {
151 t.Fatalf("sanitized indicator %q is missing %q", plain, want)
152 }
153 }
154}
155
156func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) {
157 if got, want := truncateSubagentIndicatorText("a👩<U+200D>💻abcdef", 6), "a👩<U+200D>💻..."; got != want {
158 t.Fatalf("truncated grapheme = %q, want %q", got, want)
159 }
160}
161
162func TestFormatSubagentActivityAgeFallsBackToLifecycleTimes(t *testing.T) {
163 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
164 if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{UpdatedAt: now.Add(-7 * time.Second)}, now), "7s"; got != want {
165 t.Fatalf("updated-at age = %q, want %q", got, want)
166 }
167 if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{Started: now.Add(-8 * time.Second)}, now), "8s"; got != want {
168 t.Fatalf("started-at age = %q, want %q", got, want)
169 }
170}
171
172func TestLimitSubagentActivityLinesSummarizesOmittedWorkers(t *testing.T) {
173 lines := []string{"one", "two", "three", "four"}
174 got := plainActivityLines(limitSubagentActivityLines(tui.Dark, lines, 3, 80))
175 want := []string{"one", "two", " … 2 more active subagents"}
176 if strings.Join(got, "\n") != strings.Join(want, "\n") {
177 t.Fatalf("limited lines = %#v, want %#v", got, want)
178 }
179}
180
181func TestSubagentActivityRowsFitFixedRightBarFrame(t *testing.T) {
182 release := make(chan struct{})
183 supervisor := subagents.New(subagents.Config{
184 Root: t.TempDir(),
185 RepoRoot: t.TempDir(),
186 NewRunner: func(*subagents.Agent) subagents.Runner {
187 return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
188 sink.Activity("thinking")
189 select {
190 case <-release:
191 return nil
192 case <-ctx.Done():
193 return ctx.Err()
194 }
195 })
196 },
197 })
198 defer func() {
199 close(release)
200 supervisor.StopAll()
201 }()
202 for n := 0; n < 7; n++ {
203 if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
204 Task: "inspect layout",
205 Subagent: "reviewer",
206 }); err != nil {
207 t.Fatal(err)
208 }
209 }
210
211 term := &sizedSubagentIndicatorTerminal{
212 alertTestTerminal: &alertTestTerminal{},
213 cols: 80,
214 rows: 8,
215 }
216 interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
217 interactive.rend.Resize(80, 8)
218 interactive.SetWidget("test", "right", extproto.WidgetPositionRightBar, "status", nil)
219 interactive.redraw()
220
221 output := stripANSIBytes(term.String())
222 if !strings.Contains(output, "▌") {
223 t.Fatalf("input was pushed out of the fixed frame: %q", output)
224 }
225 if !strings.Contains(output, "more active subagents") {
226 t.Fatalf("omitted subagents were not summarized: %q", output)
227 }
228 if strings.Contains(term.String(), "\x1b[-") {
229 t.Fatalf("renderer received a negative cursor row: %q", term.String())
230 }
231}
232
233func TestSubagentActivityLinesFollowInputPosition(t *testing.T) {
234 started := make(chan struct{})
235 release := make(chan struct{})
236 var releaseOnce sync.Once
237 releaseRunner := func() { releaseOnce.Do(func() { close(release) }) }
238 supervisor := subagents.New(subagents.Config{
239 Root: t.TempDir(),
240 RepoRoot: t.TempDir(),
241 NewRunner: func(*subagents.Agent) subagents.Runner {
242 return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
243 sink.Activity("thinking")
244 close(started)
245 select {
246 case <-release:
247 return nil
248 case <-ctx.Done():
249 return ctx.Err()
250 }
251 })
252 },
253 })
254 defer func() {
255 releaseRunner()
256 supervisor.StopAll()
257 }()
258 agent, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
259 Task: "inspect the input layout",
260 Subagent: "senior-engineer",
261 })
262 if err != nil {
263 t.Fatal(err)
264 }
265 select {
266 case <-started:
267 case <-time.After(time.Second):
268 t.Fatal("subagent runner did not start")
269 }
270
271 assertPosition := func(t *testing.T, position string, wantBelow bool) {
272 t.Helper()
273 term := &alertTestTerminal{}
274 interactive := NewInteractive(InteractiveConfig{
275 Terminal: term,
276 Theme: tui.Dark,
277 Supervisor: supervisor,
278 TUISubagentPosition: position,
279 })
280 interactive.clock = func() time.Time { return time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) }
281 interactive.rend.Resize(80, 24)
282 interactive.redraw()
283
284 output := stripANSIBytes(term.String())
285 inputAt := strings.LastIndex(output, "▌")
286 subagentAt := strings.LastIndex(output, "senior-engineer")
287 if inputAt < 0 || subagentAt < 0 {
288 t.Fatalf("rendered input/subagent missing from %q", output)
289 }
290 if gotBelow := subagentAt > inputAt; gotBelow != wantBelow {
291 t.Fatalf("subagent below input = %v, want %v: %q", gotBelow, wantBelow, output)
292 }
293 }
294
295 t.Run("default below", func(t *testing.T) {
296 assertPosition(t, "", true)
297 })
298 t.Run("configured above", func(t *testing.T) {
299 assertPosition(t, tui.SubagentPositionAboveInput, false)
300 })
301 t.Run("dashboard editor suppresses inline animation", func(t *testing.T) {
302 term := &alertTestTerminal{}
303 interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
304 interactive.rend.Resize(80, 24)
305 interactive.subagentsDialog.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
306 interactive.subagentsDialog.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
307 if interactive.subagentsDialog.NeedsTickRefresh() {
308 t.Fatal("subagent spawn editor should suppress dashboard ticks")
309 }
310 interactive.redraw()
311 if interactive.subagentActivityActive {
312 t.Fatal("hidden inline indicators should not request animation ticks")
313 }
314 })
315
316 releaseRunner()
317 agent.Wait()
318 term := &alertTestTerminal{}
319 interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
320 interactive.rend.Resize(80, 24)
321 interactive.redraw()
322 if output := stripANSIBytes(term.String()); strings.Contains(output, "senior-engineer") {
323 t.Fatalf("finished subagent indicator remained visible: %q", output)
324 }
325}
326
327func TestSetSubagentSessionScopeRefreshesActivityFilter(t *testing.T) {
328 release := make(chan struct{})
329 supervisor := subagents.New(subagents.Config{
330 Root: t.TempDir(),
331 RepoRoot: t.TempDir(),
332 NewRunner: func(*subagents.Agent) subagents.Runner {
333 return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
334 select {
335 case <-release:
336 return nil
337 case <-ctx.Done():
338 return ctx.Err()
339 }
340 })
341 },
342 })
343 defer func() {
344 close(release)
345 supervisor.StopAll()
346 }()
347 supervisor.SetActiveSession("session-a")
348 if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
349 Task: "inspect scope",
350 Subagent: "reviewer",
351 }); err != nil {
352 t.Fatal(err)
353 }
354
355 interactive := NewInteractive(InteractiveConfig{Supervisor: supervisor})
356 if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
357 t.Fatalf("active rows in initial scope = %d, want 1", got)
358 }
359 interactive.SetSubagentSessionScope("session-b")
360 if got := supervisor.ActiveSession(); got != "session-b" {
361 t.Fatalf("active session = %q, want session-b", got)
362 }
363 select {
364 case <-interactive.dirty:
365 case <-time.After(time.Second):
366 t.Fatal("session scope change did not invalidate the interactive view")
367 }
368 if got := len(interactive.activeSubagentActivitySnapshots()); got != 0 {
369 t.Fatalf("active rows after scope change = %d, want 0", got)
370 }
371
372 interactive.SetSubagentSessionScope("session-a")
373 if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
374 t.Fatalf("active rows after returning to scope = %d, want 1", got)
375 }
376}
377
378func plainActivityLines(lines []string) []string {
379 plain := make([]string, len(lines))
380 for idx, line := range lines {
381 plain[idx] = stripANSIBytes(line)
382 }
383 return plain
384}