v0.38.0 58133ea

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 TestResidentUsageMetadataShowsContextOnly(t *testing.T) {
237	metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500},
239		ContextUsed: 45_152, ContextMax: 272_000,
240	})
241	if strings.Contains(metadata, "budget:") {
242		t.Fatalf("usage metadata carries budget = %q", metadata)
243	}
244	if !strings.Contains(metadata, "16.6%/272k") {
245		t.Fatalf("usage metadata missing context gauge = %q", metadata)
246	}
247}
248
249func TestCompactResidentTokensAvoidsThousandKBoundary(t *testing.T) {
250	for _, tc := range []struct {
251		tokens int64
252		want   string
253	}{
254		{tokens: 999_499, want: "999k"},
255		{tokens: 999_500, want: "1.0M"},
256		{tokens: 999_999, want: "1.0M"},
257		{tokens: 1_000_000, want: "1.0M"},
258	} {
259		if got := compactResidentTokens(tc.tokens); got != tc.want {
260			t.Errorf("compactResidentTokens(%d) = %q, want %q", tc.tokens, got, tc.want)
261		}
262	}
263}
264
265func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
266	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
267	const width = 34
268	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
269		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
270		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
271		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
272	}}, width, now)
273	plain := plainResidentIndicatorLines(lines)
274	if len(plain) < 3 {
275		t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
276	}
277	if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], "    ") {
278		t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
279	}
280	joinedMetadata := strings.Join(plain[1:], " ")
281	for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
282		if !strings.Contains(joinedMetadata, want) {
283			t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
284		}
285	}
286	for _, line := range plain {
287		if gotWidth := runewidth.StringWidth(line); gotWidth > width {
288			t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
289		}
290	}
291}
292
293func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
294	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
295	const width = 34
296	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
297		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
298		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
299		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
300	}}, width, now)
301	if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
302		t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
303	}
304
305	got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
306		return len(candidate)
307	}))
308	if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
309		t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
310	}
311}
312
313func TestRenderResidentSubagentActivityLinesOmitsMetadataWhenIndentDoesNotFit(t *testing.T) {
314	now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
315	snapshot := subagents.ResidentSnapshot{
316		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
317		Usage:       provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
318		ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
319	}
320	for _, width := range []int{3, 4} {
321		t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
322			lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{snapshot}, width, now)
323			plain := plainResidentIndicatorLines(lines)
324			wantActivity := "  " + strings.Repeat(".", width-runewidth.StringWidth("  "))
325			if len(plain) != 1 || plain[0] != wantActivity {
326				t.Fatalf("activity lines = %#v, want only activity row %#v", plain, []string{wantActivity})
327			}
328			if strings.Contains(strings.Join(plain, "\n"), "↑84k") {
329				t.Fatalf("activity lines include metadata despite unavailable indent: %#v", plain)
330			}
331			if gotWidth := runewidth.StringWidth(plain[0]); gotWidth > width {
332				t.Fatalf("activity line width = %d, want <= %d: %q", gotWidth, width, plain[0])
333			}
334
335			limited := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width))
336			if len(limited) != 1 || limited[0] != plain[0] || strings.Contains(limited[0], "more active") {
337				t.Fatalf("limited activity lines = %#v, want preserved activity row %#v without overflow", limited, plain)
338			}
339		})
340	}
341}
342
343func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
344	started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
345	now := started.Add(2 * time.Second)
346	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
347		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
348		TurnStartedAt: started, ActivityUpdatedAt: started, WaitingForModel: true,
349	}}, 80, now)
350	got := strings.Join(plainResidentIndicatorLines(lines), "\n")
351	if !strings.Contains(got, "zzZz") {
352		t.Fatalf("model-wait indicator = %q, want animated waiting frame", got)
353	}
354}
355
356func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testing.T) {
357	now := time.Date(2026, time.August, 13, 12, 0, 2, 0, time.UTC)
358	lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
359		ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
360		TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now.Add(-time.Second),
361	}}, 80, now)
362	got := strings.Join(plainResidentIndicatorLines(lines), "\n")
363	if !strings.Contains(got, "/ reviewer · running · activity 1s ago · 1m00s") || strings.Contains(got, "Zzzz") {
364		t.Fatalf("tool-work indicator = %q", got)
365	}
366}
367
368func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
369	lines := []string{"  one", "  two", "  three", "  four"}
370	bottomRows := func(candidate []string) int { return 2 + len(candidate) }
371
372	allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
373	if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
374		t.Fatalf("fit lines = %#v, want all activity rows", allFit)
375	}
376
377	compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
378	wantCompact := []string{"  one", "  … 3 more active subagents"}
379	if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
380		t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
381	}
382}
383
384func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
385	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
386	want := []string{"one", "  … 2 more active subagents"}
387	if strings.Join(got, "\n") != strings.Join(want, "\n") {
388		t.Fatalf("limited lines = %#v, want %#v", got, want)
389	}
390}
391
392func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
393	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
394	want := []string{"  … 3 more active subagents"}
395	if strings.Join(got, "\n") != strings.Join(want, "\n") {
396		t.Fatalf("limited lines = %#v, want %#v", got, want)
397	}
398}
399
400func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
401	lines := []string{"  agent one", "    usage one", "  agent two", "    usage two"}
402	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
403	want := []string{"  agent one", "    usage one", "  agent two"}
404	if strings.Join(got, "\n") != strings.Join(want, "\n") {
405		t.Fatalf("limited lines = %#v, want %#v", got, want)
406	}
407}
408
409func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
410	lines := []string{"  agent one", "    usage one", "    usage continued", "  agent two", "    usage two"}
411	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
412	want := []string{"  agent one", "  … 2 more active subagents"}
413	if strings.Join(got, "\n") != strings.Join(want, "\n") {
414		t.Fatalf("limited lines = %#v, want %#v", got, want)
415	}
416}
417
418func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {
419	got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))
420	want := []string{"one", "  … 6 more active subagents"}
421	if strings.Join(got, "\n") != strings.Join(want, "\n") {
422		t.Fatalf("limited lines = %#v, want %#v", got, want)
423	}
424}
425
426func plainResidentIndicatorLines(lines []string) []string {
427	plain := make([]string, len(lines))
428	for index, line := range lines {
429		plain[index] = stripANSIBytes(line)
430	}
431	return plain
432}

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