v0.11.5 c4261b5

runner_test.go RawHistoryFolder

1package subagents
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12)
13
14// TestSubagentWorkerArgs locks in the exact flag set the subprocess
15// runner uses to start a subagent agent in daemon mode. Past
16// regressions in this area:
17//
18//   - "--no-sess" instead of "--no-session" (old print-mode runner):
19//     every spawned agent died with "unknown flag" before it could
20//     talk to the model.
21//
22//   - Forgetting --cwd: the child resolved tools against the parent
23//     zut's working directory, defeating the whole point of the
24//     worktree isolation.
25//
26//   - Forgetting --session: a daemon-mode agent without a session
27//     file would lose context between follow-up turns, making
28//     "send another message" mostly useless.
29//
30// The test asserts the load-bearing pieces are present in plausible
31// positions. If a flag is renamed, update both the runner and this
32// test so we notice immediately.
33func TestCredentialStdinHelperProcess(t *testing.T) {
34	if os.Getenv("ZUT_SUBAGENT_CREDENTIAL_HELPER") != "1" {
35		return
36	}
37	if os.Getenv("ZUT_SUBAGENT_CREDENTIAL_STDIN") != "1" {
38		os.Exit(2)
39	}
40	var credential Credential
41	if err := json.NewDecoder(os.Stdin).Decode(&credential); err != nil {
42		os.Exit(3)
43	}
44	if credential.Value != "inherited-secret" || credential.Method != "apikey" {
45		os.Exit(4)
46	}
47	for _, arg := range os.Args {
48		if strings.Contains(arg, credential.Value) {
49			os.Exit(5)
50		}
51	}
52	for _, env := range os.Environ() {
53		if strings.Contains(env, credential.Value) {
54			os.Exit(6)
55		}
56	}
57	fmt.Println(`{"type":"agent_stopped","data":{"reason":"completed"}}`)
58	os.Exit(0)
59}
60
61// TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage verifies
62// that the durable event stream has the same visible result while live and
63// after history replay.
64func TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage(t *testing.T) {
65	events := []Event{
66		NewEvent("message.delta", map[string]any{"delta": "partial "}),
67		NewEvent("message.delta", map[string]any{"delta": "answer"}),
68		NewEvent("assistant_message", map[string]any{"content": []any{
69			map[string]any{"type": "text", "text": "partial answer"},
70		}}),
71	}
72
73	live := &Agent{transcriptLoaded: true}
74	for _, event := range events {
75		applyEventToSink(event, agentSink{a: live})
76	}
77	if got := strings.Join(live.Transcript(), "\n"); got != "partial answer" {
78		t.Fatalf("live transcript = %q, want final assistant message once", got)
79	}
80	if got := live.Snapshot().LastAssistant; got != "partial answer" {
81		t.Fatalf("live assistant output = %q, want %q", got, "partial answer")
82	}
83
84	replayed := &Agent{transcriptLoaded: true}
85	for _, event := range events {
86		replayEventTranscript(replayed, event)
87	}
88	if got := strings.Join(replayed.Transcript(), "\n"); got != "partial answer" {
89		t.Fatalf("replayed transcript = %q, want final assistant message once", got)
90	}
91}
92
93func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
94	a := &Agent{maxOutputBytes: 32, maxOutputLines: 100, transcriptLoaded: true}
95	applyEventToSink(NewEvent("message.delta", map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: a})
96	applyEventToSink(NewEvent("message.delta", map[string]any{"delta": "discarded"}), agentSink{a: a})
97
98	a.mu.Lock()
99	streamed := a.streamingAssistantText
100	truncated := a.streamingAssistantTruncated
101	a.mu.Unlock()
102	if !truncated {
103		t.Fatal("streamed output was not marked truncated")
104	}
105	if got := len([]byte(streamed)); got > 32 {
106		t.Fatalf("streamed output length = %d, want at most 32", got)
107	}
108	snapshot := a.Snapshot()
109	if got := snapshot.LastAssistant; got != streamed {
110		t.Fatalf("live assistant output = %q, want bounded stream %q", got, streamed)
111	}
112	if !snapshot.OutputTruncated {
113		t.Fatal("snapshot did not expose truncated streamed output")
114	}
115
116	events := []Event{
117		NewEvent(EventMessageDelta, map[string]any{"delta": "first partial"}),
118		NewEvent("turn_end", map[string]any{"stop": "cancelled"}),
119		NewEvent(EventMessageDelta, map[string]any{"delta": "second partial"}),
120	}
121	want := "first partial\nsecond partial"
122	assertProjection := func(t *testing.T, agent *Agent) {
123		t.Helper()
124		if got := strings.Join(agent.Transcript(), "\n"); got != want {
125			t.Fatalf("transcript = %q, want %q", got, want)
126		}
127		agent.mu.Lock()
128		truncated := agent.streamingAssistantTruncated
129		agent.mu.Unlock()
130		if truncated {
131			t.Fatal("fresh second-turn stream was incorrectly truncated")
132		}
133	}
134
135	live := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
136	for _, event := range events {
137		applyEventToSink(event, agentSink{a: live})
138	}
139	assertProjection(t, live)
140
141	replayed := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
142	replayTranscriptIntoAgent(replayed, events)
143	assertProjection(t, replayed)
144
145	legacy := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
146	replayEventsIntoAgent(legacy, events)
147	assertProjection(t, legacy)
148
149	truncatedTurn := &Agent{maxOutputBytes: 16, maxOutputLines: 100, transcriptLoaded: true}
150	applyEventToSink(NewEvent(EventMessageDelta, map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: truncatedTurn})
151	applyEventToSink(NewEvent("turn_end", map[string]any{"stop": "cancelled"}), agentSink{a: truncatedTurn})
152	truncatedTurn.mu.Lock()
153	streamText := truncatedTurn.streamingAssistantText
154	streamTruncated := truncatedTurn.streamingAssistantTruncated
155	truncatedTurn.mu.Unlock()
156	if streamText != "" || streamTruncated {
157		t.Fatalf("stream state after turn boundary = (%q, %v), want empty and not truncated", streamText, streamTruncated)
158	}
159}
160
161func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
162	t.Setenv("OPENAI_API_KEY", "openai-secret")
163	t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
164	t.Setenv("HF_TOKEN", "hf-secret")
165	t.Setenv("GITHUB_TOKEN", "github-secret")
166	t.Setenv("CUSTOM_PROVIDER_TOKEN", "token-secret")
167	t.Setenv("CUSTOM_PROVIDER_KEY", "key-secret")
168	t.Setenv("AWS_ACCESS_KEY_ID", "aws-access-secret")
169	t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/tmp/service-account.json")
170	t.Setenv("ZUT_SUBAGENT_TEST_VALUE", "not-secret")
171	env := strings.Join(workerEnvironment("openai"), "\n")
172	for _, secret := range []string{
173		"OPENAI_API_KEY=openai-secret",
174		"CUSTOM_PROVIDER_API_KEY=custom-secret",
175		"HF_TOKEN=hf-secret",
176		"GITHUB_TOKEN=github-secret",
177		"CUSTOM_PROVIDER_TOKEN=token-secret",
178		"CUSTOM_PROVIDER_KEY=key-secret",
179		"AWS_ACCESS_KEY_ID=aws-access-secret",
180		"GOOGLE_APPLICATION_CREDENTIALS=/tmp/service-account.json",
181	} {
182		if strings.Contains(env, secret) {
183			t.Fatalf("worker environment leaked %s", secret)
184		}
185	}
186	if !strings.Contains(env, "ZUT_SUBAGENT_TEST_VALUE=not-secret") {
187		t.Fatal("worker environment dropped unrelated variables")
188	}
189
190	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "bedrock-secret")
191	bedrockEnv := strings.Join(workerEnvironment("amazon-bedrock"), "\n")
192	if !strings.Contains(bedrockEnv, "AWS_BEARER_TOKEN_BEDROCK=bedrock-secret") {
193		t.Fatal("Bedrock ambient credential chain was unexpectedly removed")
194	}
195	if !strings.Contains(bedrockEnv, "AWS_ACCESS_KEY_ID=aws-access-secret") {
196		t.Fatal("Bedrock AWS access-key credential was unexpectedly removed")
197	}
198}
199
200func TestExecRunnerTransfersCredentialOnlyOnStdin(t *testing.T) {
201	t.Setenv("ZUT_SUBAGENT_CREDENTIAL_HELPER", "1")
202	root := t.TempDir()
203	a := &Agent{
204		ID:           "credential-test",
205		Dir:          root,
206		Provider:     "custom",
207		SessionPath:  filepath.Join(root, "session.json"),
208		InboxPath:    filepath.Join(root, "inbox"),
209		EventLogPath: filepath.Join(root, "events.jsonl"),
210	}
211	calls := 0
212	r := &execRunner{
213		agent:   a,
214		Command: []string{os.Args[0], "-test.run=^TestCredentialStdinHelperProcess$"},
215		resolveCredential: func(context.Context, string) (Credential, error) {
216			calls++
217			return Credential{Value: "inherited-secret", Method: "apikey"}, nil
218		},
219	}
220	if err := r.Run(context.Background(), agentSink{a: a}); err != nil {
221		t.Fatal(err)
222	}
223	if calls != 1 {
224		t.Fatalf("credential resolver called %d times, want 1", calls)
225	}
226}
227
228func TestApplyEventPreservesMultilineRolesAndFinalAssistant(t *testing.T) {
229	a := &Agent{}
230	sink := agentSink{a: a}
231	applyEventToSink(NewEvent("user_message", map[string]any{"content": []any{
232		map[string]any{"type": "text", "text": "first line\n\nlast line"},
233	}}), sink)
234	applyEventToSink(NewEvent("assistant_message", map[string]any{"content": []any{
235		map[string]any{"type": "text", "text": "complete\nanswer"},
236	}}), sink)
237
238	snap := a.Snapshot()
239	wantLines := []string{"user: first line", "user: ", "user: last line", "complete", "answer"}
240	if strings.Join(snap.Lines, "\n") != strings.Join(wantLines, "\n") {
241		t.Fatalf("transcript = %#v, want %#v", snap.Lines, wantLines)
242	}
243	if snap.LastAssistant != "complete\nanswer" {
244		t.Fatalf("last assistant = %q", snap.LastAssistant)
245	}
246}
247
248func TestSubagentWorkerArgs(t *testing.T) {
249	args := subagentWorkerArgs(subagentWorkerArgsOpts{
250		Exe:         "/path/to/zut",
251		Dir:         "/tmp/worktree",
252		SessionPath: "/tmp/state/session.json",
253		InboxPath:   "/tmp/state/in.sock",
254		Task:        "do the thing",
255		Reasoning:   "high",
256		Subagent:    "reviewer",
257	})
258	if len(args) < 7 {
259		t.Fatalf("argv unexpectedly short: %v", args)
260	}
261	if args[0] != "/path/to/zut" {
262		t.Fatalf("argv[0] = %q; want the binary path", args[0])
263	}
264	// The task must come last so anything that looks flag-like in
265	// the task body doesn't get interpreted as a flag.
266	if args[len(args)-1] != "do the thing" {
267		t.Fatalf("task should be last positional; got %v", args)
268	}
269
270	mustHave := map[string]string{
271		"--subagent-worker": "/tmp/state/in.sock",
272		"--session":         "/tmp/state/session.json",
273		"--cwd":             "/tmp/worktree",
274		"--reasoning":       "high",
275		"--subagent":        "reviewer",
276	}
277	for flag, value := range mustHave {
278		i := indexOf(args, flag)
279		if i < 0 {
280			t.Errorf("argv missing %q: %v", flag, args)
281			continue
282		}
283		if i+1 >= len(args) || args[i+1] != value {
284			t.Errorf("argv %q value = %q; want %q", flag, safeAt(args, i+1), value)
285		}
286	}
287
288	// Reject prior bad flags explicitly so a future revert is caught.
289	joined := strings.Join(args, " ")
290	for _, bad := range []string{"--print", "--no-sess ", "--no-session"} {
291		if strings.Contains(joined, bad) {
292			t.Fatalf("argv contains stale/wrong flag %q: %s", bad, joined)
293		}
294	}
295}
296
297func TestDefaultChildArgsPropagatesWebSearchPolicy(t *testing.T) {
298	allow := defaultChildArgs("zut", &Agent{Task: "task", WebSearchPolicy: WebSearchAllow}, "/session", "/inbox")
299	idx := indexOf(allow, "--web-search-policy")
300	if idx < 0 || safeAt(allow, idx+1) != "allow" {
301		t.Fatalf("allow argv = %v", allow)
302	}
303
304	// A legacy/hand-built Agent has no resolved parent policy and must not
305	// inherit the child process's default-enabled normal CLI setting.
306	deny := defaultChildArgs("zut", &Agent{Task: "task"}, "/session", "/inbox")
307	idx = indexOf(deny, "--web-search-policy")
308	if idx < 0 || safeAt(deny, idx+1) != "deny" {
309		t.Fatalf("deny argv = %v", deny)
310	}
311}
312
313func TestResolveSubagentExecutableUsesCurrentWhenAvailable(t *testing.T) {
314	got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(_ context.Context, candidate string) (string, error) {
315		if candidate == "/current/bin/zut" {
316			return candidate, nil
317		}
318		return "", os.ErrNotExist
319	})
320	if err != nil {
321		t.Fatal(err)
322	}
323	if got != "/current/bin/zut" {
324		t.Fatalf("executable = %q, want current executable", got)
325	}
326}
327
328func TestResolveSubagentExecutableFallsBackToPath(t *testing.T) {
329	got, err := resolveSubagentExecutable(context.Background(), "/removed/.local/bin/zut", "/removed/.local/bin/zut", func(_ context.Context, candidate string) (string, error) {
330		if candidate == "zut" {
331			return "/active/go/bin/zut", nil
332		}
333		return "", os.ErrNotExist
334	})
335	if err != nil {
336		t.Fatal(err)
337	}
338	if got != "/active/go/bin/zut" {
339		t.Fatalf("executable = %q, want PATH fallback", got)
340	}
341}
342
343func TestResolveSubagentExecutableCancelsOtherLookupsAfterFirstSuccess(t *testing.T) {
344	slowStarted := make(chan struct{})
345	slowCanceled := make(chan struct{})
346	got, err := resolveSubagentExecutable(context.Background(), "/current/bin/zut", "/old/bin/zut", func(ctx context.Context, candidate string) (string, error) {
347		switch candidate {
348		case "/current/bin/zut":
349			close(slowStarted)
350			<-ctx.Done()
351			close(slowCanceled)
352			return "", ctx.Err()
353		case "zut":
354			<-slowStarted
355			return "/active/go/bin/zut", nil
356		default:
357			return "", os.ErrNotExist
358		}
359	})
360	if err != nil {
361		t.Fatal(err)
362	}
363	if got != "/active/go/bin/zut" {
364		t.Fatalf("executable = %q, want first successful lookup", got)
365	}
366	select {
367	case <-slowCanceled:
368	case <-time.After(time.Second):
369		t.Fatal("slower lookup was not canceled")
370	}
371}
372
373func TestSubagentWorkerArgsPropagatesProviderConnectionSettings(t *testing.T) {
374	args := subagentWorkerArgs(subagentWorkerArgsOpts{
375		Exe:         "/zut",
376		Dir:         "/work",
377		SessionPath: "/state/session.json",
378		InboxPath:   "/state/inbox.sock",
379		BaseURL:     "https://gateway.example.test/v1",
380		InsecureTLS: true,
381	})
382	if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != "https://gateway.example.test/v1" {
383		t.Fatalf("argv = %v, want --base-url with inherited endpoint", args)
384	}
385	if !containsArg(args, "--insecure") {
386		t.Fatalf("argv = %v, want inherited --insecure", args)
387	}
388	for _, arg := range args {
389		if strings.Contains(arg, "api-key") || strings.Contains(arg, "secret") {
390			t.Fatalf("argv contains credential material: %v", args)
391		}
392	}
393}
394
395func TestDefaultChildArgsPropagatesProviderConnectionSettings(t *testing.T) {
396	a := &Agent{
397		Dir:         "/work",
398		BaseURL:     "https://gateway.example.test/v1",
399		InsecureTLS: true,
400	}
401	args := defaultChildArgs("/zut", a, "/state/session.json", "/state/inbox.sock")
402	if i := indexOf(args, "--base-url"); i < 0 || safeAt(args, i+1) != a.BaseURL {
403		t.Fatalf("argv = %v, want Agent.BaseURL propagated", args)
404	}
405	if !containsArg(args, "--insecure") {
406		t.Fatalf("argv = %v, want Agent.InsecureTLS propagated", args)
407	}
408}
409
410// TestSubagentWorkerArgsEmptyTaskOmitsPositional makes sure that when the
411// agent is being adopted (no fresh task) we don't pass an empty
412// positional which the arg parser would treat as a real prompt.
413func TestSubagentWorkerArgsEmptyTaskOmitsPositional(t *testing.T) {
414	args := subagentWorkerArgs(subagentWorkerArgsOpts{
415		Exe: "/zut", Dir: "/wt", SessionPath: "/s.json", InboxPath: "/in.sock",
416	})
417	for _, a := range args {
418		if a == "" {
419			t.Fatalf("argv contains an empty positional: %v", args)
420		}
421	}
422	// last arg should be a real flag value, not a stray positional
423	if a := args[len(args)-1]; strings.HasPrefix(a, "--") {
424		t.Fatalf("argv ends on a flag with no value: %v", args)
425	}
426	for _, flag := range []string{"--reasoning", "--subagent"} {
427		if indexOf(args, flag) >= 0 {
428			t.Fatalf("empty child options should omit %s: %v", flag, args)
429		}
430	}
431}
432
433// TestDefaultChildArgsSpawnIncludesTask pins the spawn shape: a
434// fresh (non-resuming) Agent produces argv that ends with the
435// original task as a positional, so the child runs it as the
436// initial user turn.
437func TestDefaultChildArgsSpawnIncludesTask(t *testing.T) {
438	a := &Agent{Dir: "/wt", Task: "do thing"}
439	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
440	if got := args[len(args)-1]; got != "do thing" {
441		t.Fatalf("spawn argv last = %q; want %q\n%v", got, "do thing", args)
442	}
443}
444
445// TestDefaultChildArgsResumeOmitsTask is the regression for the
446// "agent busy; send 'cancel' first" error: when an Agent is being
447// resumed (Resuming==true), the child argv MUST NOT include the
448// original Task as a positional. Otherwise the child fires the task
449// as a fresh user turn on every resume, racing with whatever the
450// user types next via the inbox.
451func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
452	a := &Agent{Dir: "/wt", Task: "do thing", Resuming: true}
453	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
454	for _, v := range args {
455		if v == "do thing" {
456			t.Fatalf("resume argv contains the task; it would re-fire as a duplicate turn\n%v", args)
457		}
458	}
459	// And no trailing positional at all: every final token must be a
460	// recognized boolean flag or a value belonging to a preceding flag,
461	// never a stray empty string or the original task.
462	if got := args[len(args)-1]; got == "" {
463		t.Fatalf("resume argv ends with an empty positional: %v", args)
464	}
465}
466
467func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) {
468	a := &Agent{
469		Dir:      "/wt",
470		Task:     "review the change",
471		Resuming: true,
472	}
473	const followUp = "I applied your feedback. Please review it again."
474	a.setResumePrompt(followUp, time.Now())
475	args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
476	if got := args[len(args)-1]; got != followUp {
477		t.Fatalf("resume argv last = %q, want follow-up prompt %q\n%v", got, followUp, args)
478	}
479	for _, v := range args[:len(args)-1] {
480		if v == a.Task {
481			t.Fatalf("resume argv contains original task; it would duplicate the first turn\n%v", args)
482		}
483	}
484}
485
486func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
487	a := &Agent{}
488	a.setTurnState(TurnRunning, "turn-1")
489	persisted := 0
490	a.persistFn = func(*Agent) { persisted++ }
491
492	updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
493
494	if got := a.TurnState(); got != TurnRunning {
495		t.Fatalf("turn state = %q, want %q", got, TurnRunning)
496	}
497	if got := a.CurrentTurnID(); got != "turn-1" {
498		t.Fatalf("turn ID = %q, want turn-1", got)
499	}
500	if persisted != 0 {
501		t.Fatalf("persist called %d times for provider/tool-loop turn_end, want 0", persisted)
502	}
503}
504
505func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
506	a := &Agent{}
507	persisted := 0
508	a.persistFn = func(*Agent) { persisted++ }
509
510	updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
511
512	if got := a.TurnState(); got != TurnFailed {
513		t.Fatalf("turn state = %q, want %q", got, TurnFailed)
514	}
515	if persisted != 1 {
516		t.Fatalf("persist called %d times for terminal turn_end, want 1", persisted)
517	}
518}
519
520func TestNotifyPromptTurnEndIgnoresProviderToolLoopTurnEnd(t *testing.T) {
521	called := make(chan struct{}, 1)
522	a := &Agent{}
523	a.SetOnTurnEnd(func(step int, errMsg string) {
524		called <- struct{}{}
525	})
526
527	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
528
529	select {
530	case <-called:
531		t.Fatal("OnTurnEnd fired for provider/tool-loop turn_end without step")
532	case <-time.After(50 * time.Millisecond):
533	}
534}
535
536func TestNotifyPromptTurnEndFiresForDaemonPromptCompletion(t *testing.T) {
537	type got struct {
538		step int
539		err  string
540	}
541	called := make(chan got, 1)
542	a := &Agent{}
543	a.SetOnTurnEnd(func(step int, errMsg string) {
544		called <- got{step: step, err: errMsg}
545	})
546
547	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
548
549	select {
550	case g := <-called:
551		if g.step != 2 || g.err != "boom" {
552			t.Fatalf("callback = (%d, %q); want (2, boom)", g.step, g.err)
553		}
554	case <-time.After(time.Second):
555		t.Fatal("OnTurnEnd did not fire for prompt-level turn_end with step")
556	}
557}
558
559func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
560	a := &Agent{}
561	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(1)}))
562	notifyPromptTurnEnd(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
563
564	type notice struct {
565		step int
566		err  string
567	}
568	var got []notice
569	a.SetOnTurnEnd(func(step int, errMsg string) {
570		got = append(got, notice{step: step, err: errMsg})
571	})
572
573	want := []notice{{step: 1}, {step: 2, err: "boom"}}
574	if len(got) != len(want) {
575		t.Fatalf("replayed %d notices, want %d: %+v", len(got), len(want), got)
576	}
577	for i := range want {
578		if got[i] != want[i] {
579			t.Errorf("replayed notice %d = %+v, want %+v", i, got[i], want[i])
580		}
581	}
582}
583
584func indexOf(xs []string, want string) int {
585	for i, x := range xs {
586		if x == want {
587			return i
588		}
589	}
590	return -1
591}
592
593func safeAt(xs []string, i int) string {
594	if i < 0 || i >= len(xs) {
595		return ""
596	}
597	return xs[i]
598}

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