detached 1531234

Narrow gateway model preservation

commit
15312347ac5cc7f004c4709bc4d1628340014871
author
patriceckhart · · 2026-07-05 08:36 UTC
parent
8e8979a
signature
unsigned
4 files · +110 -46
M packages/agent/build.go +8 -32
@@ -437,30 +437,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
437437 err = nil
438438 }
439439 }
440- // Gateway/router providers (openrouter, vercel-ai-gateway, etc.) are
441- // open-catalogue too: they can route any model id from any provider.
442- // When the model isn't found under the gateway provider, try the
443- // global catalog and keep the model id as-is.
444- if err != nil && isGatewayProvider(provName) {
445- if m, findErr := provider.FindModel("", model); findErr == nil {
446- resolvedModel = provider.Model{
447- Provider: provName,
448- ID: m.ID,
449- DisplayName: m.DisplayName,
450- ContextWindow: m.ContextWindow,
451- MaxOutput: m.MaxOutput,
452- Reasoning: m.Reasoning,
453- AdaptiveThinking: m.AdaptiveThinking,
454- PriceInput: m.PriceInput,
455- PriceOutput: m.PriceOutput,
456- PriceCacheRead: m.PriceCacheRead,
457- PriceCacheWrite: m.PriceCacheWrite,
458- BaseURL: "", // let the gateway client use its default
459- Source: "catalog",
460- }
461- err = nil
462- }
463- } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
440+ if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
464441 // Fall back to the provider-level base URL when the model does
465442 // not define its own endpoint.
466443 resolvedModel.BaseURL = cfg.BaseURL
@@ -476,23 +453,22 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
476453 // explicit --model flag), repair the config so the warning
477454 // doesn't repeat on every launch.
478455
479- // Gateway providers (openrouter, vercel-ai-gateway, etc.) are
480- // open-catalogue: even model ids not in any catalog are valid
481- // as long as the upstream gateway recognises them. Create a
482- // synthetic model entry instead of overwriting the user's choice.
483- if isGatewayProvider(provName) {
456+ // Gateway providers can accept route-qualified ids that are not in
457+ // zot's local catalog yet, for example OpenRouter's
458+ // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
459+ // plain unknown values are likely typos and should still fall back.
460+ if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
484461 resolvedModel = provider.Model{
485462 Provider: provName,
486463 ID: model,
487464 DisplayName: model,
488465 ContextWindow: 1000000,
489466 MaxOutput: 64000,
490- Reasoning: true,
491467 BaseURL: "",
492- Source: "user",
468+ Source: "gateway",
493469 }
494470 err = nil
495- // Don't repair config — the model id is valid for this gateway.
471+ // Don't repair config — the routed model id may be valid upstream.
496472 } else {
497473 fallback := defaultModelForProvider(provName)
498474 fm, ferr := provider.FindModel(provName, fallback)
@@ -437,30 +437,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
437 err = nil437 err = nil
438 }438 }
439 }439 }
440- // Gateway/router providers (openrouter, vercel-ai-gateway, etc.) are440+ if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
441- // open-catalogue too: they can route any model id from any provider.
442- // When the model isn't found under the gateway provider, try the
443- // global catalog and keep the model id as-is.
444- if err != nil && isGatewayProvider(provName) {
445- if m, findErr := provider.FindModel("", model); findErr == nil {
446- resolvedModel = provider.Model{
447- Provider: provName,
448- ID: m.ID,
449- DisplayName: m.DisplayName,
450- ContextWindow: m.ContextWindow,
451- MaxOutput: m.MaxOutput,
452- Reasoning: m.Reasoning,
453- AdaptiveThinking: m.AdaptiveThinking,
454- PriceInput: m.PriceInput,
455- PriceOutput: m.PriceOutput,
456- PriceCacheRead: m.PriceCacheRead,
457- PriceCacheWrite: m.PriceCacheWrite,
458- BaseURL: "", // let the gateway client use its default
459- Source: "catalog",
460- }
461- err = nil
462- }
463- } else if cfg, ok := provider.CustomProviders()[provName]; ok && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
464 // Fall back to the provider-level base URL when the model does441 // Fall back to the provider-level base URL when the model does
465 // not define its own endpoint.442 // not define its own endpoint.
466 resolvedModel.BaseURL = cfg.BaseURL443 resolvedModel.BaseURL = cfg.BaseURL
@@ -476,23 +453,22 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
476 // explicit --model flag), repair the config so the warning453 // explicit --model flag), repair the config so the warning
477 // doesn't repeat on every launch.454 // doesn't repeat on every launch.
478 455
479- // Gateway providers (openrouter, vercel-ai-gateway, etc.) are456+ // Gateway providers can accept route-qualified ids that are not in
480- // open-catalogue: even model ids not in any catalog are valid457+ // zot's local catalog yet, for example OpenRouter's
481- // as long as the upstream gateway recognises them. Create a458+ // "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
482- // synthetic model entry instead of overwriting the user's choice.459+ // plain unknown values are likely typos and should still fall back.
483- if isGatewayProvider(provName) {460+ if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
484 resolvedModel = provider.Model{461 resolvedModel = provider.Model{
485 Provider: provName,462 Provider: provName,
486 ID: model,463 ID: model,
487 DisplayName: model,464 DisplayName: model,
488 ContextWindow: 1000000,465 ContextWindow: 1000000,
489 MaxOutput: 64000,466 MaxOutput: 64000,
490- Reasoning: true,
491 BaseURL: "",467 BaseURL: "",
492- Source: "user",468+ Source: "gateway",
493 }469 }
494 err = nil470 err = nil
495- // Don't repair config — the model id is valid for this gateway.471+ // Don't repair config — the routed model id may be valid upstream.
496 } else {472 } else {
497 fallback := defaultModelForProvider(provName)473 fallback := defaultModelForProvider(provName)
498 fm, ferr := provider.FindModel(provName, fallback)474 fm, ferr := provider.FindModel(provName, fallback)
M packages/agent/build_test.go +43 -0
@@ -112,6 +112,49 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
112112 }
113113 }
114114
115+func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
116+ t.Setenv("ZOT_HOME", t.TempDir())
117+ t.Setenv("OPENROUTER_API_KEY", "test-key")
118+ want := "deepseek/deepseek-v4-flash"
119+ if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
120+ t.Fatal(err)
121+ }
122+
123+ r, err := Resolve(Args{}, true)
124+ if err != nil {
125+ t.Fatalf("Resolve failed: %v", err)
126+ }
127+ if r.Provider != "openrouter" {
128+ t.Fatalf("provider = %q, want openrouter", r.Provider)
129+ }
130+ if r.Model != want {
131+ t.Fatalf("model = %q, want %q", r.Model, want)
132+ }
133+ if r.MaxOutput != 64000 {
134+ t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
135+ }
136+}
137+
138+func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
139+ t.Setenv("ZOT_HOME", t.TempDir())
140+ t.Setenv("OPENROUTER_API_KEY", "test-key")
141+ stale := "not-a-routed-model"
142+ if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ r, err := Resolve(Args{}, true)
147+ if err != nil {
148+ t.Fatalf("Resolve failed: %v", err)
149+ }
150+ if r.Provider != "openrouter" {
151+ t.Fatalf("provider = %q, want openrouter", r.Provider)
152+ }
153+ if r.Model == stale || r.Model == "" {
154+ t.Fatalf("model = %q, want repaired gateway default", r.Model)
155+ }
156+}
157+
115158 // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
116159 // #15: pointing ZOT_HOME at a fresh dir drops the persisted
117160 // config.json (which pinned provider=amazon-bedrock). Resolve must
@@ -112,6 +112,49 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
112 }112 }
113 }113 }
114 114
115+func TestResolveOpenRouterPreservesSavedRoutedModelID(t *testing.T) {
116+ t.Setenv("ZOT_HOME", t.TempDir())
117+ t.Setenv("OPENROUTER_API_KEY", "test-key")
118+ want := "deepseek/deepseek-v4-flash"
119+ if err := SaveConfig(Config{Provider: "openrouter", Model: want}); err != nil {
120+ t.Fatal(err)
121+ }
122+
123+ r, err := Resolve(Args{}, true)
124+ if err != nil {
125+ t.Fatalf("Resolve failed: %v", err)
126+ }
127+ if r.Provider != "openrouter" {
128+ t.Fatalf("provider = %q, want openrouter", r.Provider)
129+ }
130+ if r.Model != want {
131+ t.Fatalf("model = %q, want %q", r.Model, want)
132+ }
133+ if r.MaxOutput != 64000 {
134+ t.Fatalf("MaxOutput = %d, want synthetic gateway default 64000", r.MaxOutput)
135+ }
136+}
137+
138+func TestResolveGatewayPlainUnknownModelFallsBack(t *testing.T) {
139+ t.Setenv("ZOT_HOME", t.TempDir())
140+ t.Setenv("OPENROUTER_API_KEY", "test-key")
141+ stale := "not-a-routed-model"
142+ if err := SaveConfig(Config{Provider: "openrouter", Model: stale}); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ r, err := Resolve(Args{}, true)
147+ if err != nil {
148+ t.Fatalf("Resolve failed: %v", err)
149+ }
150+ if r.Provider != "openrouter" {
151+ t.Fatalf("provider = %q, want openrouter", r.Provider)
152+ }
153+ if r.Model == stale || r.Model == "" {
154+ t.Fatalf("model = %q, want repaired gateway default", r.Model)
155+ }
156+}
157+
115 // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue158 // TestResolveEnvOnlyBedrockDiscoveredWithoutConfig reproduces issue
116 // #15: pointing ZOT_HOME at a fresh dir drops the persisted159 // #15: pointing ZOT_HOME at a fresh dir drops the persisted
117 // config.json (which pinned provider=amazon-bedrock). Resolve must160 // config.json (which pinned provider=amazon-bedrock). Resolve must
M packages/agent/modelsync.go +22 -14
@@ -5,6 +5,7 @@ import (
55 "fmt"
66 "os"
77 "path/filepath"
8+ "strings"
89 "time"
910
1011 "github.com/patriceckhart/zot/packages/provider"
@@ -46,20 +47,28 @@ func LoadUserModels() {
4647 provider.SetUserModels(models)
4748 }
4849
49-// isGatewayProvider returns true for providers that act as routers or
50-// gateways serving models from any provider in the catalog (OpenRouter,
51-// Vercel AI Gateway, Cloudflare AI Gateway, etc.). For these providers
52-// the model id is never repaired — a model that lives under "deepseek"
53-// in the catalog is perfectly valid when served through OpenRouter.
50+// isGatewayProvider returns true for providers whose OpenAI-compatible
51+// endpoint can accept routed model IDs that are not present in zot's local
52+// catalog. Vercel AI Gateway is intentionally not listed here: zot currently
53+// talks to it through the Anthropic-compatible client, which still requires
54+// catalog metadata for request shaping.
5455 func isGatewayProvider(prov string) bool {
5556 switch prov {
56- case "openrouter", "vercel-ai-gateway", "cloudflare-ai-gateway":
57+ case "openrouter", "cloudflare-ai-gateway":
5758 return true
5859 default:
5960 return false
6061 }
6162 }
6263
64+// isGatewayRoutedModelID reports whether a model looks like the routed IDs
65+// used by gateway providers, for example "deepseek/deepseek-v4-flash".
66+// Non-routed typos like "deepseek-v4-flashh" should still be repaired to a
67+// known default instead of being silently accepted.
68+func isGatewayRoutedModelID(model string) bool {
69+ return strings.Contains(strings.TrimSpace(model), "/")
70+}
71+
6372 // ValidateAndRepairConfig checks the persisted config.json's
6473 // (Provider, Model) pair against the active catalog and repairs any
6574 // mismatch in-place (and on disk) before any UI renders. Three failure
@@ -71,9 +80,8 @@ func isGatewayProvider(prov string) bool {
7180 // (e.g. provider=anthropic + model=kimi-for-coding from a stale
7281 // half-applied switch) -> reset model to the provider's default.
7382 //
74-// Gateway providers (openrouter, vercel-ai-gateway, cloudflare-ai-gateway)
75-// are exempt from the cross-provider model check because they can serve
76-// any model from the catalog.
83+// Gateway providers are exempt from the cross-provider model check for routed
84+// model IDs because those IDs can be valid even when absent from zot's catalog.
7785 //
7886 // Silent on success; one stderr line per repair. Errors loading or
7987 // saving the file are non-fatal — the caller continues with defaults.
@@ -94,11 +102,11 @@ func ValidateAndRepairConfig() {
94102
95103 if cfg.Provider != "" && cfg.Model != "" {
96104 if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil {
97- // Gateway providers (openrouter, vercel-ai-gateway, etc.) can
98- // serve any model from the catalog. A model id that belongs to
99- // "deepseek" is perfectly valid when served through OpenRouter.
100- if isGatewayProvider(cfg.Provider) {
101- // Provider is a router; skip the cross-provider model repair.
105+ // Gateway providers can serve routed model ids like
106+ // "deepseek/deepseek-v4-flash" even when the local catalog does not
107+ // know them. Preserve only routed ids; plain typos are still repaired.
108+ if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model) {
109+ // Provider is a router and the id is route-qualified; preserve it.
102110 } else if m, err := provider.FindModel("", cfg.Model); err == nil {
103111 fix := defaultModelForProvider(cfg.Provider)
104112 fmt.Fprintf(os.Stderr,
@@ -5,6 +5,7 @@ import (
5 "fmt"5 "fmt"
6 "os"6 "os"
7 "path/filepath"7 "path/filepath"
8+ "strings"
8 "time"9 "time"
9 10
10 "github.com/patriceckhart/zot/packages/provider"11 "github.com/patriceckhart/zot/packages/provider"
@@ -46,20 +47,28 @@ func LoadUserModels() {
46 provider.SetUserModels(models)47 provider.SetUserModels(models)
47 }48 }
48 49
49-// isGatewayProvider returns true for providers that act as routers or50+// isGatewayProvider returns true for providers whose OpenAI-compatible
50-// gateways serving models from any provider in the catalog (OpenRouter,51+// endpoint can accept routed model IDs that are not present in zot's local
51-// Vercel AI Gateway, Cloudflare AI Gateway, etc.). For these providers52+// catalog. Vercel AI Gateway is intentionally not listed here: zot currently
52-// the model id is never repaired — a model that lives under "deepseek"53+// talks to it through the Anthropic-compatible client, which still requires
53-// in the catalog is perfectly valid when served through OpenRouter.54+// catalog metadata for request shaping.
54 func isGatewayProvider(prov string) bool {55 func isGatewayProvider(prov string) bool {
55 switch prov {56 switch prov {
56- case "openrouter", "vercel-ai-gateway", "cloudflare-ai-gateway":57+ case "openrouter", "cloudflare-ai-gateway":
57 return true58 return true
58 default:59 default:
59 return false60 return false
60 }61 }
61 }62 }
62 63
64+// isGatewayRoutedModelID reports whether a model looks like the routed IDs
65+// used by gateway providers, for example "deepseek/deepseek-v4-flash".
66+// Non-routed typos like "deepseek-v4-flashh" should still be repaired to a
67+// known default instead of being silently accepted.
68+func isGatewayRoutedModelID(model string) bool {
69+ return strings.Contains(strings.TrimSpace(model), "/")
70+}
71+
63 // ValidateAndRepairConfig checks the persisted config.json's72 // ValidateAndRepairConfig checks the persisted config.json's
64 // (Provider, Model) pair against the active catalog and repairs any73 // (Provider, Model) pair against the active catalog and repairs any
65 // mismatch in-place (and on disk) before any UI renders. Three failure74 // mismatch in-place (and on disk) before any UI renders. Three failure
@@ -71,9 +80,8 @@ func isGatewayProvider(prov string) bool {
71 // (e.g. provider=anthropic + model=kimi-for-coding from a stale80 // (e.g. provider=anthropic + model=kimi-for-coding from a stale
72 // half-applied switch) -> reset model to the provider's default.81 // half-applied switch) -> reset model to the provider's default.
73 //82 //
74-// Gateway providers (openrouter, vercel-ai-gateway, cloudflare-ai-gateway)83+// Gateway providers are exempt from the cross-provider model check for routed
75-// are exempt from the cross-provider model check because they can serve84+// model IDs because those IDs can be valid even when absent from zot's catalog.
76-// any model from the catalog.
77 //85 //
78 // Silent on success; one stderr line per repair. Errors loading or86 // Silent on success; one stderr line per repair. Errors loading or
79 // saving the file are non-fatal — the caller continues with defaults.87 // saving the file are non-fatal — the caller continues with defaults.
@@ -94,11 +102,11 @@ func ValidateAndRepairConfig() {
94 102
95 if cfg.Provider != "" && cfg.Model != "" {103 if cfg.Provider != "" && cfg.Model != "" {
96 if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil {104 if _, err := provider.FindModel(cfg.Provider, cfg.Model); err != nil {
97- // Gateway providers (openrouter, vercel-ai-gateway, etc.) can105+ // Gateway providers can serve routed model ids like
98- // serve any model from the catalog. A model id that belongs to106+ // "deepseek/deepseek-v4-flash" even when the local catalog does not
99- // "deepseek" is perfectly valid when served through OpenRouter.107+ // know them. Preserve only routed ids; plain typos are still repaired.
100- if isGatewayProvider(cfg.Provider) {108+ if isGatewayProvider(cfg.Provider) && isGatewayRoutedModelID(cfg.Model) {
101- // Provider is a router; skip the cross-provider model repair.109+ // Provider is a router and the id is route-qualified; preserve it.
102 } else if m, err := provider.FindModel("", cfg.Model); err == nil {110 } else if m, err := provider.FindModel("", cfg.Model); err == nil {
103 fix := defaultModelForProvider(cfg.Provider)111 fix := defaultModelForProvider(cfg.Provider)
104 fmt.Fprintf(os.Stderr,112 fmt.Fprintf(os.Stderr,
M packages/agent/modelsync_test.go +37 -0
@@ -101,6 +101,43 @@ func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *t
101101 }
102102 }
103103
104+func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
105+ home := t.TempDir()
106+ t.Setenv("ZOT_HOME", home)
107+
108+ want := "deepseek/deepseek-v4-flash"
109+ b, _ := json.Marshal(Config{Provider: "openrouter", Model: want})
110+ _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
111+
112+ ValidateAndRepairConfig()
113+
114+ out, _ := LoadConfig()
115+ if out.Provider != "openrouter" {
116+ t.Errorf("provider mutated: %q", out.Provider)
117+ }
118+ if out.Model != want {
119+ t.Errorf("routed model id mutated: got %q, want %q", out.Model, want)
120+ }
121+}
122+
123+func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) {
124+ home := t.TempDir()
125+ t.Setenv("ZOT_HOME", home)
126+
127+ b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"})
128+ _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
129+
130+ ValidateAndRepairConfig()
131+
132+ out, _ := LoadConfig()
133+ if out.Provider != "openrouter" {
134+ t.Errorf("provider mutated: %q", out.Provider)
135+ }
136+ if out.Model == "not-a-routed-model" || out.Model == "" {
137+ t.Errorf("plain unknown gateway model was not repaired: %q", out.Model)
138+ }
139+}
140+
104141 // TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
105142 func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
106143 home := t.TempDir()
@@ -101,6 +101,43 @@ func TestValidateAndRepairConfig_DuplicateModelIDValidForConfiguredProvider(t *t
101 }101 }
102 }102 }
103 103
104+func TestValidateAndRepairConfig_OpenRouterPreservesRoutedModelID(t *testing.T) {
105+ home := t.TempDir()
106+ t.Setenv("ZOT_HOME", home)
107+
108+ want := "deepseek/deepseek-v4-flash"
109+ b, _ := json.Marshal(Config{Provider: "openrouter", Model: want})
110+ _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
111+
112+ ValidateAndRepairConfig()
113+
114+ out, _ := LoadConfig()
115+ if out.Provider != "openrouter" {
116+ t.Errorf("provider mutated: %q", out.Provider)
117+ }
118+ if out.Model != want {
119+ t.Errorf("routed model id mutated: got %q, want %q", out.Model, want)
120+ }
121+}
122+
123+func TestValidateAndRepairConfig_GatewayPlainUnknownModelStillRepairs(t *testing.T) {
124+ home := t.TempDir()
125+ t.Setenv("ZOT_HOME", home)
126+
127+ b, _ := json.Marshal(Config{Provider: "openrouter", Model: "not-a-routed-model"})
128+ _ = os.WriteFile(filepath.Join(home, "config.json"), b, 0o644)
129+
130+ ValidateAndRepairConfig()
131+
132+ out, _ := LoadConfig()
133+ if out.Provider != "openrouter" {
134+ t.Errorf("provider mutated: %q", out.Provider)
135+ }
136+ if out.Model == "not-a-routed-model" || out.Model == "" {
137+ t.Errorf("plain unknown gateway model was not repaired: %q", out.Model)
138+ }
139+}
140+
104 // TestValidateAndRepairConfig_HappyPath leaves a valid config alone.141 // TestValidateAndRepairConfig_HappyPath leaves a valid config alone.
105 func TestValidateAndRepairConfig_HappyPath(t *testing.T) {142 func TestValidateAndRepairConfig_HappyPath(t *testing.T) {
106 home := t.TempDir()143 home := t.TempDir()

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