v0.26.0 7e66a81

resident_subagent_indicator_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/bnema/zut/packages/agent/subagents"
11	"github.com/bnema/zut/packages/provider"
12	"github.com/bnema/zut/packages/tui"
13	"github.com/mattn/go-runewidth"
14)
15
16func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T) {
17	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
18	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{
19		{ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},
20		{ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},
21		{ID: "foreign-id", Profile: "another process", State: subagents.ResidentRunning, OwnedElsewhere: true, TurnStartedAt: now.Add(-10 * time.Second)},
22		{ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},
23	}, 80, now)
24	got := strings.Join(plainResidentIndicatorLines(lines), "\n")
25	if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {
26		t.Fatalf("activity lines = %q", got)
27	}
28	if strings.Contains(got, "finished") || strings.Contains(got, "another process") {
29		t.Fatalf("activity lines include inactive or foreign child: %q", got)
30	}
31}
32
33type residentIndicatorTerminal struct {
34	alertTestTerminal
35	cols int
36	rows int
37}
38
39func (t *residentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
40func (t *residentIndicatorTerminal) Reset() {
41	t.mu.Lock()
42	defer t.mu.Unlock()
43	t.data = nil
44}
45
46func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) {
47	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
48		return func(ctx context.Context, _ string) error {
49			<-ctx.Done()
50			return ctx.Err()
51		}, nil
52	})
53	t.Cleanup(func() { _ = manager.Close(context.Background()) })
54	spawnRunningResidentIndicatorChildren(t, manager, 10)
55
56	term := &residentIndicatorTerminal{cols: 80, rows: 40}
57	interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
58	interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
59	interactive.rend.Resize(term.cols, term.rows)
60	term.Reset()
61	interactive.redraw()
62
63	plain := stripANSIBytes(term.String())
64	for index := 0; index < 10; index++ {
65		if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
66			t.Fatalf("interactive output missing %q:\n%s", want, plain)
67		}
68	}
69
70	if err := manager.Close(context.Background()); err != nil {
71		t.Fatal(err)
72	}
73	term.Reset()
74	interactive.redraw()
75	plain = stripANSIBytes(term.String())
76	if strings.Contains(plain, "agent-00") {
77		t.Fatalf("interactive output kept finished subagent activity:\n%s", plain)
78	}
79}
80
81func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) {
82	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
83		return func(ctx context.Context, _ string) error {
84			<-ctx.Done()
85			return ctx.Err()
86		}, nil
87	})
88	t.Cleanup(func() { _ = manager.Close(context.Background()) })
89	spawnRunningResidentIndicatorChildren(t, manager, 6)
90
91	term := &residentIndicatorTerminal{cols: 80, rows: 8}
92	interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
93	interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
94	interactive.rend.Resize(term.cols, term.rows)
95	term.Reset()
96	interactive.redraw()
97
98	plain := stripANSIBytes(term.String())
99	if count := strings.Count(plain, "agent-"); count != 1 {
100		t.Fatalf("interactive output showed %d active child rows, want one fitting row under short height:\n%s", count, plain)
101	}
102	if !strings.Contains(plain, "… 5 more active subagents") {
103		t.Fatalf("interactive output did not summarize omitted active children:\n%s", plain)
104	}
105}
106
107func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) {
108	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
109		return func(ctx context.Context, _ string) error {
110			<-ctx.Done()
111			return ctx.Err()
112		}, nil
113	})
114	t.Cleanup(func() { _ = manager.Close(context.Background()) })
115	spawnRunningResidentIndicatorChildren(t, manager, 2)
116
117	term := &residentIndicatorTerminal{cols: 80, rows: 8}
118	interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
119	interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
120	interactive.rend.Resize(term.cols, term.rows)
121	term.Reset()
122	interactive.redraw()
123
124	plain := stripANSIBytes(term.String())
125	for index := 0; index < 2; index++ {
126		if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
127			t.Fatalf("interactive output missing fitting active child %q:\n%s", want, plain)
128		}
129	}
130	if strings.Contains(plain, "more active subagents") {
131		t.Fatalf("interactive output summarized fitting active children:\n%s", plain)
132	}
133}
134
135func spawnRunningResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
136	t.Helper()
137	updates := make(chan string, count*2)
138	manager.SetUpdateObserver(func(childID string) {
139		select {
140		case updates <- childID:
141		default:
142		}
143	})
144	spawnResidentIndicatorChildren(t, manager, count)
145	defer manager.SetUpdateObserver(nil)
146
147	running := make(map[string]struct{}, count)
148	timer := time.NewTimer(time.Second)
149	defer timer.Stop()
150	for len(running) < count {
151		select {
152		case childID := <-updates:
153			snapshot, ok := manager.SnapshotFor(childID)
154			if ok && snapshot.State == subagents.ResidentRunning {
155				running[childID] = struct{}{}
156			}
157		case <-timer.C:
158			snapshots, _ := manager.ActiveSnapshotPage(0)
159			t.Fatalf("running resident children = %d, want %d; active snapshots=%#v", len(running), count, snapshots)
160		}
161	}
162}
163
164func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
165	t.Helper()
166	for index := 0; index < count; index++ {
167		spec := subagents.ResidentChildSpec{
168			ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
169			Provider: "openai", Model: "gpt-test",
170		}
171		if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
172			t.Fatalf("Spawn(%d): %v", index, err)
173		}
174	}
175}
176
177func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
178	manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
179		return func(ctx context.Context, _ string) error {
180			<-ctx.Done()
181			return ctx.Err()
182		}, nil
183	})
184	t.Cleanup(func() { _ = manager.Close(context.Background()) })
185	spawnResidentIndicatorChildren(t, manager, 5)
186
187	lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC))
188	plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
189	if hidden != 3 {
190		t.Fatalf("hidden active children = %d, want 3", hidden)
191	}
192	if count := strings.Count(plain, "agent-"); count != 2 {
193		t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain)
194	}
195}
196
197func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
198	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
199	var snapshots []subagents.ResidentSnapshot
200	for index := 0; index < 12; index++ {
201		snapshots = append(snapshots, subagents.ResidentSnapshot{
202			ID: fmt.Sprintf("child-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), State: subagents.ResidentRunning,
203			TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now,
204		})
205	}
206	lines := renderResidentSubagentActivityLines(tui.Dark, "/", snapshots, 80, now)
207	plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
208	for index := 0; index < 12; index++ {
209		if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
210			t.Fatalf("activity lines missing %q:\n%s", want, plain)
211		}
212	}
213}
214
215func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t *testing.T) {
216	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
217	const width = 120
218	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
219		ID: "running-id", Profile: "審査", State: subagents.ResidentRunning,
220		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
221		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
222	}}, width, now)
223	plain := plainResidentIndicatorLines(lines)
224	if len(plain) != 1 {
225		t.Fatalf("activity lines = %#v, want one right-aligned row", plain)
226	}
227	metadata := "↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k"
228	if !strings.HasSuffix(plain[0], metadata) {
229		t.Fatalf("activity line = %q, want metadata suffix %q", plain[0], metadata)
230	}
231	if gotWidth := runewidth.StringWidth(plain[0]); gotWidth != width {
232		t.Fatalf("activity line width = %d, want %d: %q", gotWidth, width, plain[0])
233	}
234}
235
236func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
237	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
238	const width = 34
239	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
240		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
241		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
242		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
243	}}, width, now)
244	plain := plainResidentIndicatorLines(lines)
245	if len(plain) < 3 {
246		t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
247	}
248	if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], "    ") {
249		t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
250	}
251	joinedMetadata := strings.Join(plain[1:], " ")
252	for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
253		if !strings.Contains(joinedMetadata, want) {
254			t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
255		}
256	}
257	for _, line := range plain {
258		if gotWidth := runewidth.StringWidth(line); gotWidth > width {
259			t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
260		}
261	}
262}
263
264func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
265	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
266	const width = 34
267	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
268		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
269		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
270		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
271	}}, width, now)
272	if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
273		t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
274	}
275
276	got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
277		return len(candidate)
278	}))
279	if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
280		t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
281	}
282}
283
284func TestRenderResidentSubagentActivityLinesOmitsMetadataWhenIndentDoesNotFit(t *testing.T) {
285	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
286	snapshot := subagents.ResidentSnapshot{
287		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
288		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
289		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
290	}
291	for _, width := range []int{3, 4} {
292		t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
293			lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{snapshot}, width, now)
294			plain := plainResidentIndicatorLines(lines)
295			wantActivity := "  " + strings.Repeat(".", width-runewidth.StringWidth("  "))
296			if len(plain) != 1 || plain[0] != wantActivity {
297				t.Fatalf("activity lines = %#v, want only activity row %#v", plain, []string{wantActivity})
298			}
299			if strings.Contains(strings.Join(plain, "\n"), "↑84k") {
300				t.Fatalf("activity lines include metadata despite unavailable indent: %#v", plain)
301			}
302			if gotWidth := runewidth.StringWidth(plain[0]); gotWidth > width {
303				t.Fatalf("activity line width = %d, want <= %d: %q", gotWidth, width, plain[0])
304			}
305
306			limited := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width))
307			if len(limited) != 1 || limited[0] != plain[0] || strings.Contains(limited[0], "more active") {
308				t.Fatalf("limited activity lines = %#v, want preserved activity row %#v without overflow", limited, plain)
309			}
310		})
311	}
312}
313
314func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
315	started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
316	now := started.Add(2 * time.Second)
317	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
318		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
319		TurnStartedAt: started, ActivityUpdatedAt: started, WaitingForModel: true,
320	}}, 80, now)
321	got := strings.Join(plainResidentIndicatorLines(lines), "\n")
322	if !strings.Contains(got, "zzZz") {
323		t.Fatalf("model-wait indicator = %q, want animated waiting frame", got)
324	}
325}
326
327func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testing.T) {
328	now := time.Date(2026, time.August, 13, 12, 0, 2, 0, time.UTC)
329	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
330		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
331		TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now.Add(-time.Second),
332	}}, 80, now)
333	got := strings.Join(plainResidentIndicatorLines(lines), "\n")
334	if !strings.Contains(got, "/ reviewer · running · activity 1s ago · 1m00s") || strings.Contains(got, "Zzzz") {
335		t.Fatalf("tool-work indicator = %q", got)
336	}
337}
338
339func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
340	lines := []string{"  one", "  two", "  three", "  four"}
341	bottomRows := func(candidate []string) int { return 2 + len(candidate) }
342
343	allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
344	if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
345		t.Fatalf("fit lines = %#v, want all activity rows", allFit)
346	}
347
348	compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
349	wantCompact := []string{"  one", "  … 3 more active subagents"}
350	if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
351		t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
352	}
353}
354
355func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
356	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
357	want := []string{"one", "  … 2 more active subagents"}
358	if strings.Join(got, "\n") != strings.Join(want, "\n") {
359		t.Fatalf("limited lines = %#v, want %#v", got, want)
360	}
361}
362
363func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
364	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
365	want := []string{"  … 3 more active subagents"}
366	if strings.Join(got, "\n") != strings.Join(want, "\n") {
367		t.Fatalf("limited lines = %#v, want %#v", got, want)
368	}
369}
370
371func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
372	lines := []string{"  agent one", "    usage one", "  agent two", "    usage two"}
373	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
374	want := []string{"  agent one", "    usage one", "  agent two"}
375	if strings.Join(got, "\n") != strings.Join(want, "\n") {
376		t.Fatalf("limited lines = %#v, want %#v", got, want)
377	}
378}
379
380func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
381	lines := []string{"  agent one", "    usage one", "    usage continued", "  agent two", "    usage two"}
382	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
383	want := []string{"  agent one", "  … 2 more active subagents"}
384	if strings.Join(got, "\n") != strings.Join(want, "\n") {
385		t.Fatalf("limited lines = %#v, want %#v", got, want)
386	}
387}
388
389func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {
390	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))
391	want := []string{"one", "  … 6 more active subagents"}
392	if strings.Join(got, "\n") != strings.Join(want, "\n") {
393		t.Fatalf("limited lines = %#v, want %#v", got, want)
394	}
395}
396
397func plainResidentIndicatorLines(lines []string) []string {
398	plain := make([]string, len(lines))
399	for index, line := range lines {
400		plain[index] = stripANSIBytes(line)
401	}
402	return plain
403}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close