v0.9.1 406ddf4

build_test.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"net/http"
7	"net/http/httptest"
8	"os"
9	"path/filepath"
10	"strings"
11	"testing"
12
13	"github.com/bnema/zut/packages/agent/skills"
14	"github.com/bnema/zut/packages/agent/subagents"
15	"github.com/bnema/zut/packages/agent/tools"
16	"github.com/bnema/zut/packages/core"
17	"github.com/bnema/zut/packages/provider"
18)
19
20type bundledSkillSource struct {
21	bundled []*skills.Skill
22}
23
24func (s bundledSkillSource) Tools() []ExtensionToolInfo { return nil }
25func (s bundledSkillSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
26	return nil
27}
28func (s bundledSkillSource) Skills() []*skills.Skill { return s.bundled }
29
30type webSearchConflictSource struct{}
31
32func (webSearchConflictSource) Tools() []ExtensionToolInfo {
33	return []ExtensionToolInfo{{Extension: "test", Name: "web_search"}}
34}
35func (webSearchConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
36	return &tools.ReadTool{}
37}
38
39type unorderedExtensionSource struct{}
40
41func (unorderedExtensionSource) Tools() []ExtensionToolInfo {
42	return []ExtensionToolInfo{
43		{Extension: "test", Name: "zeta"},
44		{Extension: "test", Name: "alpha"},
45	}
46}
47func (unorderedExtensionSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
48	return &tools.ReadTool{}
49}
50
51func TestMergeExtensionToolsCreatesSkillToolWhenDiscoveryFoundNoSkills(t *testing.T) {
52	r := Resolved{
53		CWD:           t.TempDir(),
54		ToolRegistry:  make(core.Registry),
55		skillsEnabled: true,
56	}
57	bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
58
59	r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
60
61	if r.SkillTool == nil {
62		t.Fatal("MergeExtensionTools did not create a skill tool for bundled skills")
63	}
64	if _, ok := r.ToolRegistry[r.SkillTool.Name()]; !ok {
65		t.Fatalf("skill tool %q was not registered", r.SkillTool.Name())
66	}
67	if got := r.SkillTool.Skills(); len(got) != 1 || got[0].Name != bundled.Name {
68		t.Fatalf("merged bundled skills = %#v", got)
69	}
70}
71
72func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
73	r := Resolved{
74		CWD:           t.TempDir(),
75		ToolRegistry:  make(core.Registry),
76		skillsEnabled: false,
77	}
78	bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
79
80	r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
81
82	if r.SkillTool != nil {
83		t.Fatal("disabled skill discovery created a skill tool")
84	}
85}
86
87func TestMergeExtensionToolsReservesWebSearchName(t *testing.T) {
88	r := Resolved{ToolRegistry: make(core.Registry)}
89	r.MergeExtensionTools(webSearchConflictSource{})
90	if _, ok := r.ToolRegistry["web_search"]; ok {
91		t.Fatal("extension replaced reserved native web_search name")
92	}
93}
94
95func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) {
96	r := Resolved{
97		CWD:          t.TempDir(),
98		ToolRegistry: buildToolRegistry(Args{}, t.TempDir(), nil, false, false, false),
99	}
100	r.MergeExtensionTools(unorderedExtensionSource{})
101
102	var names []string
103	for _, summary := range r.ToolSummary {
104		names = append(names, summary.Name)
105	}
106	want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta"
107	if got := strings.Join(names, ","); got != want {
108		t.Fatalf("merged tool summary order = %q, want %q", got, want)
109	}
110
111	r.MergeExtensionTools(unorderedExtensionSource{})
112	names = names[:0]
113	for _, summary := range r.ToolSummary {
114		names = append(names, summary.Name)
115	}
116	if got := strings.Join(names, ","); got != want {
117		t.Fatalf("reloaded tool summary order = %q, want %q", got, want)
118	}
119}
120
121func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
122	r := Resolved{
123		CWD:           t.TempDir(),
124		ToolRegistry:  make(core.Registry),
125		skillsEnabled: true,
126		systemAppend:  []string{PonytailSystemAddendum()},
127	}
128	bundled := &skills.Skill{Name: "bundled", Description: "bundled skill", Source: "extension tasked-phases"}
129
130	r.MergeExtensionTools(bundledSkillSource{bundled: []*skills.Skill{bundled}})
131
132	if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
133		t.Fatalf("extension prompt rebuild lost Ponytail addendum:\n%s", r.SystemPrompt)
134	}
135}
136
137func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
138	root := t.TempDir()
139	sandbox := tools.NewSandbox(root)
140	registry := buildToolRegistry(Args{}, root, sandbox, true, true, false)
141	lspTool, ok := registry["lsp"].(*tools.LSPTool)
142	if !ok || lspTool.Manager == nil {
143		t.Fatalf("lsp tool = %#v", registry["lsp"])
144	}
145	t.Cleanup(func() { _ = lspTool.Manager.Close() })
146	if _, ok := registry["lsp"]; !ok {
147		t.Fatal("default registry does not include lsp")
148	}
149	worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool)
150	if !ok || worktree.CWD != root || worktree.Sandbox != sandbox {
151		t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"])
152	}
153	if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {
154		t.Fatalf("default registry web_search = %#v", registry["web_search"])
155	}
156	write, ok := registry["write"].(*tools.WriteTool)
157	if !ok || write.LSP == nil || !write.LSPDiagnostics {
158		t.Fatalf("write tool LSP wiring = %#v", write)
159	}
160	edit, ok := registry["edit"].(*tools.EditTool)
161	if !ok || edit.LSP == nil || edit.LSPDiagnostics {
162		t.Fatalf("edit tool LSP wiring = %#v", edit)
163	}
164	if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 {
165		t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled))
166	}
167	readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
168	if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
169		t.Fatalf("read-only registry = %#v", readOnly)
170	}
171	if _, ok := readOnly["web_search"]; ok {
172		t.Fatalf("explicit non-matching tools list retained web search: %#v", readOnly)
173	}
174	webOnly := buildToolRegistry(Args{Tools: []string{"web_search"}}, root, tools.NewSandbox(root), true, true, true)
175	if web, ok := webOnly["web_search"].(*tools.WebSearchTool); !ok || web == nil || len(webOnly) != 1 {
176		t.Fatalf("web-search-only registry = %#v", webOnly)
177	}
178	workerMissingPolicy := buildToolRegistry(Args{Mode: ModeSubagentWorker}, root, tools.NewSandbox(root), false, false, false)
179	if _, ok := workerMissingPolicy["web_search"]; ok {
180		t.Fatal("worker registry enabled web_search without an explicit propagated allow")
181	}
182	workerAllowed := buildToolRegistry(Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, root, tools.NewSandbox(root), false, false, false)
183	if _, ok := workerAllowed["web_search"].(*tools.WebSearchTool); !ok {
184		t.Fatalf("worker propagated allow registry = %#v", workerAllowed)
185	}
186	workerCapped := buildToolRegistry(Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, root, tools.NewSandbox(root), false, false, false)
187	if _, ok := workerCapped["web_search"]; ok {
188		t.Fatalf("worker tool list ceiling was bypassed: %#v", workerCapped)
189	}
190	worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true)
191	if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 {
192		t.Fatalf("worktree-only registry = %#v", worktreeOnly)
193	}
194}
195
196func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) {
197	root := t.TempDir()
198	registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
199
200	summaries := toolSummaries(registry, Args{})
201	want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"}
202	if len(summaries) != len(want) {
203		t.Fatalf("tool summaries = %#v, want %v", summaries, want)
204	}
205	for index, summary := range summaries {
206		if summary.Name != want[index] {
207			t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index])
208		}
209	}
210}
211
212func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) {
213	root := t.TempDir()
214	previous := tools.NewSandbox(root)
215	shared := tools.NewSandbox(root)
216	worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous}
217	resolved := &Resolved{
218		Sandbox:      previous,
219		ToolRegistry: core.NewRegistry(worktree),
220	}
221
222	resolved.UseSandbox(shared)
223
224	if resolved.Sandbox != shared {
225		t.Fatal("resolved sandbox was not replaced")
226	}
227	if worktree.Sandbox != shared {
228		t.Fatal("worktree tool sandbox was not replaced")
229	}
230}
231
232func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
233	for _, tc := range []struct {
234		name       string
235		tools      []string
236		wantSpawn  bool
237		wantStatus bool
238		wantAny    bool
239	}{
240		{name: "default", wantSpawn: true, wantStatus: true, wantAny: true},
241		{name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},
242		{name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},
243		{name: "other", tools: []string{"read"}},
244		{name: "no tools", wantAny: false, tools: nil},
245	} {
246		t.Run(tc.name, func(t *testing.T) {
247			args := Args{Tools: tc.tools}
248			if tc.name == "no tools" {
249				args.NoTools = true
250			}
251			if got := autoSubagentsToolAllowed(args); got != tc.wantSpawn {
252				t.Fatalf("spawn allowed = %v, want %v", got, tc.wantSpawn)
253			}
254			if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {
255				t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)
256			}
257			if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {
258				t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)
259			}
260		})
261	}
262}
263
264func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
265	root := t.TempDir()
266	zutHome := filepath.Join(root, "zut-home")
267	project := filepath.Join(root, "repo")
268	nested := filepath.Join(project, "packages", "app")
269	if err := os.MkdirAll(zutHome, 0o755); err != nil {
270		t.Fatal(err)
271	}
272	if err := os.MkdirAll(nested, 0o755); err != nil {
273		t.Fatal(err)
274	}
275	if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global rule"), 0o644); err != nil {
276		t.Fatal(err)
277	}
278	if err := os.WriteFile(filepath.Join(project, "AGENTS.md"), []byte("repo rule"), 0o644); err != nil {
279		t.Fatal(err)
280	}
281	if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("app rule"), 0o644); err != nil {
282		t.Fatal(err)
283	}
284
285	files := loadAgentsContext(nested, zutHome)
286	if len(files) != 3 {
287		t.Fatalf("loaded %d context files, want 3: %#v", len(files), files)
288	}
289	wantPaths := []string{
290		filepath.Join(zutHome, "AGENTS.md"),
291		filepath.Join(project, "AGENTS.md"),
292		filepath.Join(nested, "AGENTS.md"),
293	}
294	for idx, want := range wantPaths {
295		if files[idx].Path != want {
296			t.Fatalf("context file %d path = %q, want %q", idx, files[idx].Path, want)
297		}
298	}
299
300	got := formatAgentsContext(files)
301	for _, want := range []string{"global rule", "repo rule", "app rule"} {
302		if !strings.Contains(got, want) {
303			t.Fatalf("formatAgentsContext missing %q in:\n%s", want, got)
304		}
305	}
306	if strings.Index(got, "global rule") > strings.Index(got, "repo rule") || strings.Index(got, "repo rule") > strings.Index(got, "app rule") {
307		t.Fatalf("AGENTS.md files loaded in wrong order:\n%s", got)
308	}
309}
310
311func TestFindSubagentProfileReportsDiscoveryFailure(t *testing.T) {
312	profileSource := filepath.Join(t.TempDir(), "profiles.md")
313	if err := os.WriteFile(profileSource, []byte("not a directory"), 0o600); err != nil {
314		t.Fatal(err)
315	}
316	t.Setenv("ZUT_AGENT_PROFILES", profileSource)
317
318	profile, err := findSubagentProfile("", "reviewer")
319	if profile != nil {
320		t.Fatalf("profile = %#v, want nil", profile)
321	}
322	if err == nil || !strings.Contains(err.Error(), "discover subagent profiles") {
323		t.Fatalf("error = %v, want discovery failure", err)
324	}
325	if strings.Contains(err.Error(), profileSource) {
326		t.Fatalf("error leaked profile path: %v", err)
327	}
328}
329
330func TestResolveIncludesNamedSubagentsListWhenAutoSubagentsIsEnabled(t *testing.T) {
331	root := t.TempDir()
332	home := filepath.Join(root, "home")
333	zutHome := filepath.Join(root, "zut-home")
334	project := filepath.Join(root, "repo")
335	if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
336		t.Fatal(err)
337	}
338	if err := os.MkdirAll(project, 0o755); err != nil {
339		t.Fatal(err)
340	}
341	t.Setenv("HOME", home)
342	t.Setenv("ZUT_HOME", zutHome)
343	t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
344	enabled := true
345	if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSubagentsEnabled: &enabled}); err != nil {
346		t.Fatal(err)
347	}
348	profile := `---
349name: reviewer
350description: Read-only code reviewer
351thinking: high
352---
353Review without editing.
354`
355	if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
356		t.Fatal(err)
357	}
358
359	r, err := Resolve(Args{CWD: project}, false)
360	if err != nil {
361		t.Fatal(err)
362	}
363	for _, want := range []string{"[subagents_list]", "reviewer", "Read-only code reviewer", "[/subagents_list]"} {
364		if !strings.Contains(r.SystemPrompt, want) {
365			t.Fatalf("system prompt missing %q:\n%s", want, r.SystemPrompt)
366		}
367	}
368}
369
370func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
371	root := t.TempDir()
372	home := filepath.Join(root, "home")
373	zutHome := filepath.Join(root, "zut-home")
374	project := filepath.Join(root, "repo")
375	if err := os.MkdirAll(filepath.Join(home, ".agents", "agents"), 0o755); err != nil {
376		t.Fatal(err)
377	}
378	if err := os.MkdirAll(project, 0o755); err != nil {
379		t.Fatal(err)
380	}
381	t.Setenv("HOME", home)
382	t.Setenv("ZUT_HOME", zutHome)
383	t.Setenv("ZUT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents"))
384	if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil {
385		t.Fatal(err)
386	}
387	if err := os.WriteFile(filepath.Join(zutHome, "AGENTS.md"), []byte("global context"), 0o600); err != nil {
388		t.Fatal(err)
389	}
390	profile := `---
391name: reviewer
392description: Read-only code reviewer
393tools: read
394thinking: off
395systemPromptMode: replace
396inheritProjectContext: false
397inheritSkills: false
398---
399You are a read-only reviewer.
400`
401	if err := os.WriteFile(filepath.Join(home, ".agents", "agents", "reviewer.md"), []byte(profile), 0o600); err != nil {
402		t.Fatal(err)
403	}
404
405	r, err := Resolve(Args{CWD: project, Subagent: "reviewer"}, false)
406	if err != nil {
407		t.Fatal(err)
408	}
409	if r.Reasoning != "" {
410		t.Fatalf("profile thinking=off did not override config reasoning: %q", r.Reasoning)
411	}
412	if _, ok := r.ToolRegistry["read"]; !ok || len(r.ToolRegistry) != 1 {
413		t.Fatalf("profile tools = %#v, want only read", r.ToolRegistry)
414	}
415	if len(r.ContextFiles) != 0 {
416		t.Fatalf("profile inheritProjectContext=false loaded %#v", r.ContextFiles)
417	}
418	if !strings.Contains(r.SystemPrompt, "You are a read-only reviewer.") || strings.Contains(r.SystemPrompt, "global context") {
419		t.Fatalf("profile system prompt inheritance is wrong:\n%s", r.SystemPrompt)
420	}
421	if !strings.Contains(r.SystemPrompt, PonytailSystemAddendum()) {
422		t.Fatalf("replace-mode profile lost global Ponytail addendum:\n%s", r.SystemPrompt)
423	}
424}
425
426func TestResolveSubagentFastModeProfileOverridesHostSetting(t *testing.T) {
427	cases := []struct {
428		name         string
429		hostFastMode bool
430		profileFast  *bool
431		argFastMode  bool
432		argFastSet   bool
433		wantFastMode bool
434	}{
435		{name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true},
436		{name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false},
437		{name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false},
438		{name: "true enables disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: true},
439		{name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false},
440		{name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false},
441	}
442	for _, tc := range cases {
443		t.Run(tc.name, func(t *testing.T) {
444			root := t.TempDir()
445			home := filepath.Join(root, "home")
446			zutHome := filepath.Join(root, "zut-home")
447			project := filepath.Join(root, "repo")
448			profilesDir := filepath.Join(home, ".agents", "agents")
449			if err := os.MkdirAll(profilesDir, 0o755); err != nil {
450				t.Fatal(err)
451			}
452			if err := os.MkdirAll(project, 0o755); err != nil {
453				t.Fatal(err)
454			}
455			t.Setenv("HOME", home)
456			t.Setenv("ZUT_HOME", zutHome)
457			t.Setenv("ZUT_AGENT_PROFILES", profilesDir)
458
459			cfg := Config{Provider: "openai", Model: "gpt-5"}
460			cfg.FastMode = &tc.hostFastMode
461			if err := SaveConfig(cfg); err != nil {
462				t.Fatal(err)
463			}
464			profile := "---\nname: reviewer\n"
465			if tc.profileFast != nil {
466				profile += fmt.Sprintf("fastMode: %t\n", *tc.profileFast)
467			}
468			profile += "---\nReview the requested scope.\n"
469			if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil {
470				t.Fatal(err)
471			}
472
473			r, err := Resolve(Args{
474				CWD:         project,
475				Subagent:    "reviewer",
476				FastMode:    tc.argFastMode,
477				FastModeSet: tc.argFastSet,
478			}, false)
479			if err != nil {
480				t.Fatal(err)
481			}
482			if r.FastMode != tc.wantFastMode {
483				t.Fatalf("FastMode = %v, want %v", r.FastMode, tc.wantFastMode)
484			}
485		})
486	}
487}
488
489func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) {
490	files := loadAgentsContext(t.TempDir(), t.TempDir())
491	if len(files) != 0 {
492		t.Fatalf("expected no context files, got %#v", files)
493	}
494	if got := formatAgentsContext(files); got != "" {
495		t.Fatalf("expected no context, got %q", got)
496	}
497}
498
499// TestResolveFallsBackWhenConfiguredModelIsGone reproduces the
500// startup failure caught by the user's screenshot: the persisted
501// config.json points at a model id that's no longer in the active
502// catalogue (because they edited models.json or zut's bundled
503// catalogue changed). Resolve must NOT error — strands the user
504// with no way to fix it from the TUI — and should repair the config
505// so the next launch is silent.
506func TestResolveFallsBackWhenConfiguredModelIsGone(t *testing.T) {
507	t.Setenv("ZUT_HOME", t.TempDir())
508	t.Setenv("OPENAI_API_KEY", "test-key")
509	// Persist a stale model id.
510	stale := "gpt-5.5-pro-not-real"
511	if err := SaveConfig(Config{Provider: "openai", Model: stale}); err != nil {
512		t.Fatal(err)
513	}
514
515	r, err := Resolve(Args{}, false)
516	if err != nil {
517		t.Fatalf("Resolve refused to launch with stale model: %v", err)
518	}
519	if r.Model == stale {
520		t.Fatalf("Resolve kept stale model %q", r.Model)
521	}
522	if r.Provider != "openai" {
523		t.Errorf("provider drifted: got %q; want openai", r.Provider)
524	}
525
526	// Config on disk should now hold the fallback so subsequent
527	// launches don't repeat the warning.
528	cfg, _ := LoadConfig()
529	if cfg.Model == stale {
530		t.Errorf("config.json still pins the stale model %q", cfg.Model)
531	}
532	if cfg.Model == "" {
533		t.Errorf("config.json was emptied; expected the fallback model id")
534	}
535}
536
537func TestResolveAppliesJailByDefault(t *testing.T) {
538	for _, tc := range []struct {
539		name   string
540		config Config
541		want   bool
542	}{
543		{name: "missing defaults to unlocked", config: Config{Provider: "openai", Model: "gpt-5"}},
544		{name: "enabled starts locked", config: Config{Provider: "openai", Model: "gpt-5", JailByDefault: boolPtr(true)}, want: true},
545	} {
546		t.Run(tc.name, func(t *testing.T) {
547			t.Setenv("ZUT_HOME", t.TempDir())
548			t.Setenv("OPENAI_API_KEY", "test-key")
549			if err := SaveConfig(tc.config); err != nil {
550				t.Fatal(err)
551			}
552
553			r, err := Resolve(Args{}, false)
554			if err != nil {
555				t.Fatalf("Resolve failed: %v", err)
556			}
557			if got := r.Sandbox.Locked(); got != tc.want {
558				t.Fatalf("sandbox locked = %v, want %v", got, tc.want)
559			}
560		})
561	}
562}
563
564func boolPtr(v bool) *bool { return &v }
565
566// TestResolveExplicitFlagStaleDoesNotRepairConfig confirms the
567// repair-on-disk happens ONLY when the stale id came from the
568// persisted config. If the user passed --model X explicitly and X is
569// unknown, we still fall back, but we don't touch their config.
570func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
571	t.Setenv("ZUT_HOME", t.TempDir())
572	t.Setenv("OPENAI_API_KEY", "test-key")
573	good := "gpt-5"
574	if err := SaveConfig(Config{Provider: "openai", Model: good}); err != nil {
575		t.Fatal(err)
576	}
577
578	r, err := Resolve(Args{Model: "gpt-totally-fake"}, false)
579	if err != nil {
580		t.Fatalf("Resolve errored on unknown --model: %v", err)
581	}
582	if r.Model == "gpt-totally-fake" {
583		t.Errorf("Resolve kept the bogus --model value")
584	}
585	cfg, _ := LoadConfig()
586	if cfg.Model != good {
587		t.Errorf("config.json was clobbered (was %q; now %q)", good, cfg.Model)
588	}
589}
590
591func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
592	t.Setenv("ZUT_HOME", t.TempDir())
593	t.Setenv("OPENROUTER_API_KEY", "test-key")
594	want := "deepseek/deepseek-v4-flash"
595	if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
596		t.Fatal(err)
597	}
598
599	r, err := Resolve(Args{}, true)
600	if err != nil {
601		t.Fatalf("Resolve failed: %v", err)
602	}
603	if r.Provider != "openrouter" {
604		t.Fatalf("provider = %q, want openrouter", r.Provider)
605	}
606	if r.Model != want {
607		t.Fatalf("model = %q, want %q", r.Model, want)
608	}
609	if r.MaxOutput != 64000 {
610		t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
611	}
612}
613
614func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
615	t.Setenv("ZUT_HOME", t.TempDir())
616	t.Setenv("OPENROUTER_API_KEY", "test-key")
617	stale := "not-a-routed-model"
618	if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
619		t.Fatal(err)
620	}
621
622	r, err := Resolve(Args{}, true)
623	if err != nil {
624		t.Fatalf("Resolve failed: %v", err)
625	}
626	if r.Provider != "openrouter" {
627		t.Fatalf("provider = %q, want openrouter", r.Provider)
628	}
629	if r.Model == stale || r.Model == "" {
630		t.Fatalf("model = %q, want repaired gateway default", r.Model)
631	}
632}
633
634// TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
635// #15: pointing ZUT_HOME at a fresh dir drops the persisted
636// config.json (which pinned provider=amazon-bedrock). Resolve must
637// still discover bedrock from the AWS env vars instead of falling back
638// to anthropic and reporting "not logged in".
639func TestResolveEnvOnlyBedrockDiscoveredWithoutConfig(t *testing.T) {
640	t.Setenv("ZUT_HOME", t.TempDir()) // fresh home: no config.json
641	// Disable the Kimi CLI token fallback so a developer machine with a
642	// real Kimi CLI login doesn't pre-empt bedrock in the scan.
643	if err := SetKimiCLIFallbackDisabled(true); err != nil {
644		t.Fatal(err)
645	}
646	t.Setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-token")
647	t.Setenv("AWS_REGION", "us-east-1")
648	// Make sure no other provider's env credential pre-empts bedrock.
649	for _, k := range []string{"ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", "DEEPSEEK_API_KEY", "KIMI_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_API_KEY", "OPENCODE_API_KEY"} {
650		t.Setenv(k, "")
651	}
652
653	r, err := Resolve(Args{}, true)
654	if err != nil {
655		t.Fatalf("Resolve errored with env-only bedrock: %v", err)
656	}
657	if r.Provider != "amazon-bedrock" {
658		t.Fatalf("provider = %q, want amazon-bedrock", r.Provider)
659	}
660	if !r.HasCredential() {
661		t.Fatalf("bedrock credential not resolved from env")
662	}
663}
664
665func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
666	t.Setenv("ZUT_HOME", t.TempDir())
667	provider.SetLiveModels(nil)
668	defer provider.SetLiveModels(nil)
669	provider.SetUserModels([]provider.Model{{
670		Provider:      "ollama",
671		ID:            "qwen-local",
672		DisplayName:   "Qwen Local",
673		ContextWindow: 32768,
674		MaxOutput:     8192,
675		BaseURL:       "http://localhost:8000/v1",
676	}})
677
678	r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
679	if err != nil {
680		t.Fatalf("Resolve failed: %v", err)
681	}
682	if r.BaseURL != "http://localhost:8000/v1" {
683		t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
684	}
685}
686
687func TestResolveUsesInheritedSupervisorCredential(t *testing.T) {
688	t.Setenv("ZUT_HOME", t.TempDir())
689	t.Setenv("OPENAI_API_KEY", "")
690
691	r, err := Resolve(Args{
692		Provider:            "openai",
693		Model:               "gpt-5",
694		inheritedCredential: "inherited-key",
695		inheritedAuthMethod: "apikey",
696		inheritedAccountID:  "account-id",
697	}, true)
698	if err != nil {
699		t.Fatal(err)
700	}
701	if r.Credential != "inherited-key" || r.AuthMethod != "apikey" || r.AccountID != "account-id" {
702		t.Fatalf("inherited credential was not preserved: %+v", r)
703	}
704}
705
706func TestResolveLlamaCPPUsesRouterInferenceURL(t *testing.T) {
707	t.Setenv("ZUT_HOME", t.TempDir())
708	t.Setenv("LLAMA_BASE_URL", "http://127.0.0.1:8080/v1/")
709	t.Setenv("LLAMA_API_KEY", "")
710	provider.SetManagedModels(nil)
711	t.Cleanup(func() { provider.SetManagedModels(nil) })
712
713	r, err := Resolve(Args{Provider: "llama.cpp", Model: "local-model"}, true)
714	if err != nil {
715		t.Fatalf("Resolve failed: %v", err)
716	}
717	if r.BaseURL != "http://127.0.0.1:8080/v1" {
718		t.Fatalf("BaseURL = %q", r.BaseURL)
719	}
720	if r.Credential != "local" || !r.HasCredential() {
721		t.Fatalf("credential = %q", r.Credential)
722	}
723	if got := r.NewClient().Name(); got != provider.LlamaCPPProviderID {
724		t.Fatalf("client name = %q, want %q", got, provider.LlamaCPPProviderID)
725	}
726}
727
728func TestResolveLlamaCPPUsesStoredLogin(t *testing.T) {
729	t.Setenv("ZUT_HOME", t.TempDir())
730	t.Setenv("LLAMA_BASE_URL", "")
731	t.Setenv("LLAMA_API_KEY", "")
732	if err := AuthStoreFor().SetEndpointCredential("llama.cpp", "http://localhost:9090", "stored-key"); err != nil {
733		t.Fatal(err)
734	}
735
736	r, err := Resolve(Args{Provider: "llama.cpp", Model: "stored-model"}, true)
737	if err != nil {
738		t.Fatalf("Resolve failed: %v", err)
739	}
740	if r.BaseURL != "http://localhost:9090/v1" || r.Credential != "stored-key" {
741		t.Fatalf("resolved = base %q credential %q", r.BaseURL, r.Credential)
742	}
743}
744
745func TestResolveCustomProviderModelBaseURLBeatsProviderBaseURL(t *testing.T) {
746	t.Setenv("ZUT_HOME", t.TempDir())
747	t.Setenv("MY_COMPANY_API_KEY", "test-key")
748	path := filepath.Join(t.TempDir(), "models.json")
749	if err := os.WriteFile(path, []byte(`{
750		"providers": {
751			"my-company": {
752				"baseUrl": "https://provider.example.com/v1",
753				"api": "openai",
754				"models": [
755					{"id": "fast", "baseUrl": "https://model.example.com/v1"}
756				]
757			}
758		}
759	}`), 0o644); err != nil {
760		t.Fatal(err)
761	}
762	models, warnings := provider.LoadUserModelsWithWarnings(path)
763	if len(warnings) != 0 {
764		t.Fatalf("warnings = %v", warnings)
765	}
766	provider.SetLiveModels(nil)
767	provider.SetUserModels(models)
768	t.Cleanup(func() { provider.SetLiveModels(nil) })
769
770	r, err := Resolve(Args{Provider: "my-company", Model: "fast"}, true)
771	if err != nil {
772		t.Fatalf("Resolve failed: %v", err)
773	}
774	if r.BaseURL != "https://model.example.com/v1" {
775		t.Fatalf("BaseURL = %q, want model-level baseUrl", r.BaseURL)
776	}
777}
778
779func TestCustomProviderUsesOpenAIResponsesAPI(t *testing.T) {
780	requestPath := make(chan string, 1)
781	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
782		requestPath <- r.URL.Path
783		w.Header().Set("Content-Type", "text/event-stream")
784		w.WriteHeader(http.StatusOK)
785	}))
786	t.Cleanup(srv.Close)
787
788	path := filepath.Join(t.TempDir(), "models.json")
789	if err := os.WriteFile(path, []byte(`{
790		"providers": {
791			"company-responses": {
792				"baseUrl": "`+srv.URL+`/v1",
793				"api": "openai-responses",
794				"models": [{"id": "reasoning-model", "reasoning": true}]
795			}
796		}
797	}`), 0o644); err != nil {
798		t.Fatal(err)
799	}
800	models, warnings := provider.LoadUserModelsWithWarnings(path)
801	if len(warnings) != 0 {
802		t.Fatalf("warnings = %v", warnings)
803	}
804	provider.SetLiveModels(nil)
805	provider.SetUserModels(models)
806	t.Cleanup(func() { provider.SetLiveModels(nil) })
807
808	r := Resolved{
809		Provider:   "company-responses",
810		Credential: "test-key",
811		BaseURL:    srv.URL + "/v1",
812	}
813	events, err := r.NewClient().Stream(context.Background(), provider.Request{
814		Model:    "reasoning-model",
815		Messages: []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}}},
816	})
817	if err != nil {
818		t.Fatal(err)
819	}
820	for range events {
821	}
822	if got := <-requestPath; got != "/v1/responses" {
823		t.Fatalf("request path = %q, want /v1/responses", got)
824	}
825}
826
827func TestResolveCustomProviderInsecureFromModelsJSONBaseURL(t *testing.T) {
828	t.Setenv("ZUT_HOME", t.TempDir())
829	t.Setenv("LOCAL_PROXY_API_KEY", "test-key")
830	path := filepath.Join(t.TempDir(), "models.json")
831	if err := os.WriteFile(path, []byte(`{
832		"providers": {
833			"local-proxy": {
834				"baseUrl": "https://proxy.example.com/v1",
835				"api": "openai",
836				"models": [{"id": "default"}]
837			}
838		}
839	}`), 0o644); err != nil {
840		t.Fatal(err)
841	}
842	models, warnings := provider.LoadUserModelsWithWarnings(path)
843	if len(warnings) != 0 {
844		t.Fatalf("warnings = %v", warnings)
845	}
846	provider.SetLiveModels(nil)
847	provider.SetUserModels(models)
848	t.Cleanup(func() { provider.SetLiveModels(nil) })
849
850	r, err := Resolve(Args{Provider: "local-proxy", Model: "default", InsecureTLS: true}, true)
851	if err != nil {
852		t.Fatalf("Resolve failed: %v", err)
853	}
854	if !r.InsecureTLS {
855		t.Fatal("InsecureTLS must be set for --insecure with models.json custom baseUrl")
856	}
857}
858
859func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
860	t.Setenv("ZUT_HOME", t.TempDir())
861	provider.SetLiveModels(nil)
862	defer provider.SetLiveModels(nil)
863
864	r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
865	if err != nil {
866		t.Fatalf("Resolve failed: %v", err)
867	}
868	if r.BaseURL != "http://localhost:11434" {
869		t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
870	}
871}
872
873func TestCanonicalProviderResolvesAliases(t *testing.T) {
874	cases := map[string]string{
875		"bedrock":         "amazon-bedrock",
876		"AWS-Bedrock":     "amazon-bedrock",
877		"  bedrock  ":     "amazon-bedrock",
878		"vertex":          "google-vertex",
879		"gemini":          "google",
880		"azure":           "azure-openai-responses",
881		"copilot":         "github-copilot",
882		"codex":           "openai-codex",
883		"moonshot":        "moonshotai",
884		"vercel":          "vercel-ai-gateway",
885		"hf":              "huggingface",
886		"anthropic":       "anthropic",       // canonical passes through
887		"amazon-bedrock":  "amazon-bedrock",  // already canonical
888		"totally-unknown": "totally-unknown", // unknown returned unchanged (lowered)
889		"Totally-UNKNOWN": "totally-unknown",
890		"":                "",
891	}
892	for in, want := range cases {
893		if got := canonicalProvider(in); got != want {
894			t.Errorf("canonicalProvider(%q) = %q, want %q", in, got, want)
895		}
896	}
897}
898
899func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
900	for alias, canon := range providerAliases {
901		if !isKnownProvider(canon) {
902			t.Errorf("alias %q maps to %q which is not a known provider", alias, canon)
903		}
904	}
905}
906
907func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
908	orig := http.DefaultTransport
909	t.Cleanup(func() { http.DefaultTransport = orig })
910
911	t.Setenv("ZUT_HOME", t.TempDir())
912	t.Setenv("OPENAI_API_KEY", "test-key")
913
914	resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
915	if err != nil {
916		t.Fatalf("Resolve: %v", err)
917	}
918	if resolved.InsecureTLS {
919		t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
920	}
921	assertDefaultTransportStillSecure(t)
922
923	resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
924	if err != nil {
925		t.Fatalf("Resolve: %v", err)
926	}
927	if !resolved.InsecureTLS {
928		t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
929	}
930	assertDefaultTransportStillSecure(t)
931}
932
933func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
934	orig := http.DefaultTransport
935	t.Cleanup(func() { http.DefaultTransport = orig })
936
937	t.Setenv("ZUT_HOME", t.TempDir())
938	t.Setenv("OPENAI_API_KEY", "test-key")
939	if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
940		t.Fatal(err)
941	}
942
943	resolved, err := Resolve(Args{Provider: "openai"}, false)
944	if err != nil {
945		t.Fatalf("Resolve: %v", err)
946	}
947	if resolved.InsecureTLS {
948		t.Fatal("InsecureTLS must not be set without a custom base URL")
949	}
950	assertDefaultTransportStillSecure(t)
951
952	resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
953	if err != nil {
954		t.Fatalf("Resolve: %v", err)
955	}
956	if !resolved.InsecureTLS {
957		t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
958	}
959	assertDefaultTransportStillSecure(t)
960}
961
962func TestDefaultXAIModelIsGrok45(t *testing.T) {
963	if got := defaultModelForProvider("xai"); got != "grok-4.5" {
964		t.Fatalf("default xAI model = %q, want grok-4.5", got)
965	}
966}
967
968func assertDefaultTransportStillSecure(t *testing.T) {
969	t.Helper()
970	tr, ok := http.DefaultTransport.(*http.Transport)
971	if !ok {
972		return
973	}
974	if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
975		t.Fatal("http.DefaultTransport must not be made insecure")
976	}
977}
978
979func TestResolveWebSearchRegistryRespectsConfigAndExplicitCLI(t *testing.T) {
980	t.Setenv("ZUT_HOME", t.TempDir())
981	disabled := false
982	if err := SaveConfig(Config{WebSearchEnabled: &disabled}); err != nil {
983		t.Fatal(err)
984	}
985	args := Args{CWD: t.TempDir(), Provider: "ollama", Model: "any-local-model"}
986	resolved, err := Resolve(args, false)
987	if err != nil {
988		t.Fatal(err)
989	}
990	if _, ok := resolved.ToolRegistry["web_search"]; ok {
991		t.Fatal("persisted opt-out left web_search in the registry")
992	}
993
994	args.ToolsSet = true
995	args.Tools = []string{"web_search"}
996	resolved, err = Resolve(args, false)
997	if err != nil {
998		t.Fatal(err)
999	}
1000	if _, ok := resolved.ToolRegistry["web_search"]; !ok {
1001		t.Fatal("explicit --tools web_search did not override persisted opt-out")
1002	}
1003
1004	args.PermissionSet = &tools.PermissionSet{}
1005	resolved, err = Resolve(args, false)
1006	if err != nil {
1007		t.Fatal(err)
1008	}
1009	if _, ok := resolved.ToolRegistry["web_search"]; ok {
1010		t.Fatal("packaged-agent PermissionSet received web_search")
1011	}
1012}
1013
1014func TestResolveWebSearchPolicy(t *testing.T) {
1015	disabled := false
1016	cases := []struct {
1017		name    string
1018		args    Args
1019		cfg     Config
1020		cfgErr  error
1021		profile *subagents.Profile
1022		want    subagents.WebSearchPolicy
1023	}{
1024		{name: "legacy config", want: subagents.WebSearchAllow},
1025		{name: "persisted opt out", cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchDeny},
1026		{name: "explicit CLI allow overrides opt out", args: Args{ToolsSet: true, Tools: []string{"web_search"}}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1027		{name: "explicit empty CLI list denies", args: Args{ToolsSet: true}, want: subagents.WebSearchDeny},
1028		{name: "explicit nonmatching CLI list denies", args: Args{ToolsSet: true, Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1029		{name: "no tools denies", args: Args{NoTools: true}, want: subagents.WebSearchDeny},
1030		{name: "packaged agent denies", args: Args{PermissionSet: &tools.PermissionSet{}}, want: subagents.WebSearchDeny},
1031		{name: "internal policy denies", args: Args{WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1032		{name: "internal policy allows", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1033		{name: "normal explicit empty list narrows internal allow", args: Args{ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1034		{name: "worker missing policy denies despite legacy config", args: Args{Mode: ModeSubagentWorker}, want: subagents.WebSearchDeny},
1035		{name: "worker explicit inherit denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchInherit}, want: subagents.WebSearchDeny},
1036		{name: "worker invalid policy denies", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchPolicy(99)}, want: subagents.WebSearchDeny},
1037		{name: "worker propagated deny remains denied", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchDeny}, want: subagents.WebSearchDeny},
1038		{name: "worker propagated allow", args: Args{Mode: ModeSubagentWorker, WebSearchPolicy: subagents.WebSearchAllow}, cfg: Config{WebSearchEnabled: &disabled}, want: subagents.WebSearchAllow},
1039		{name: "worker explicit empty list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1040		{name: "worker nonmatching list caps allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1041		{name: "worker matching list preserves allow", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read", "web_search"}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchAllow},
1042		{name: "worker no tools caps allow", args: Args{Mode: ModeSubagentWorker, NoTools: true, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1043		{name: "worker permission set caps allow", args: Args{Mode: ModeSubagentWorker, PermissionSet: &tools.PermissionSet{}, WebSearchPolicy: subagents.WebSearchAllow}, want: subagents.WebSearchDeny},
1044		{name: "config load failure denies inherited policy", cfgErr: fmt.Errorf("broken config"), want: subagents.WebSearchDeny},
1045		{name: "named profile requires explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read"}}, want: subagents.WebSearchDeny},
1046		{name: "named profile allows explicit tool", args: Args{WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"read", "web_search"}}, want: subagents.WebSearchAllow},
1047		{name: "named profile cannot rescue worker inherit", args: Args{Mode: ModeSubagentWorker}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1048		{name: "named profile cannot bypass worker tool list", args: Args{Mode: ModeSubagentWorker, ToolsSet: true, Tools: []string{"read"}, WebSearchPolicy: subagents.WebSearchAllow}, profile: &subagents.Profile{Tools: []string{"web_search"}}, want: subagents.WebSearchDeny},
1049	}
1050	for _, tc := range cases {
1051		t.Run(tc.name, func(t *testing.T) {
1052			if got := resolveWebSearchPolicy(tc.args, tc.cfg, tc.cfgErr, tc.profile); got != tc.want {
1053				t.Fatalf("policy = %v, want %v", got, tc.want)
1054			}
1055		})
1056	}
1057}

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