v0.12.0 0c5f8d5

subagent_indicator_test.go RawHistoryFolder

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}

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