v0.3.90 6f6753c

build.go RawHistoryFolder

1package agent
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"path/filepath"
8	"runtime"
9	"strings"
10
11	zotdocs "github.com/patriceckhart/zot"
12	"github.com/patriceckhart/zot/packages/agent/extensions"
13	"github.com/patriceckhart/zot/packages/agent/skills"
14	"github.com/patriceckhart/zot/packages/agent/tools"
15	"github.com/patriceckhart/zot/packages/core"
16	"github.com/patriceckhart/zot/packages/provider"
17)
18
19// ContextFile is an instruction file loaded into the system prompt.
20type ContextFile struct {
21	Path    string
22	Content string
23}
24
25// Resolved is the effective configuration after merging CLI, config, defaults.
26type Resolved struct {
27	Provider    string
28	Model       string
29	Credential  string // api key or oauth access token
30	AuthMethod  string // "apikey" | "oauth" | "" (no credential yet)
31	AccountID   string // ChatGPT account id (for openai oauth), "" otherwise
32	BaseURL     string
33	InsecureTLS bool
34	CWD         string
35	Reasoning   string
36	Temperature *float32
37
38	ToolRegistry core.Registry
39	ToolSummary  []ToolSummary
40	SystemPrompt string
41	MaxSteps     int
42	Sandbox      *tools.Sandbox
43
44	// MaxOutput is the resolved model's maximum output-token budget
45	// (from the catalog). Passed to the agent so each turn requests
46	// the model's full output capacity instead of the provider's
47	// conservative default (e.g. Bedrock's 4096, which truncates
48	// long writes/edits with stopReason=length).
49	MaxOutput int
50
51	// SkillTool is the on-demand skill loader registered with the
52	// agent's tool registry, or nil if no SKILL.md files were
53	// discovered. Exposed so the tui can list / preview skills.
54	SkillTool *skills.Tool
55
56	// SkillDiagnostics records non-fatal skill discovery failures and
57	// shadowing decisions so each host mode can report them appropriately.
58	SkillDiagnostics []string
59
60	// ContextFiles records the AGENTS.md files appended to SystemPrompt,
61	// in effective load order. Interactive mode uses this metadata to make
62	// otherwise invisible startup context inspectable without adding fake
63	// messages to the provider transcript.
64	ContextFiles []ContextFile
65
66	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
67	// so the system prompt can be rebuilt later without re-running
68	// resolve.
69	systemAppend     []string
70	systemCustom     string
71	systemCustomSet  bool
72	toolDescriptions map[string]string
73}
74
75// HasCredential reports whether a credential was resolved.
76func (r Resolved) HasCredential() bool { return r.Credential != "" }
77
78// MergeExtensionTools folds every tool registered by an extension
79// into r's ToolRegistry and re-renders the system prompt's tool
80// summary so the model sees both built-in and extension tools.
81//
82// Idempotent: calling twice with the same manager state has no
83// effect on the second pass (existing names are preserved). Built-in
84// tools always win on conflict.
85func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
86	if mgr == nil {
87		return
88	}
89	infos := mgr.Tools()
90	if len(infos) == 0 {
91		return
92	}
93	changed := false
94	for _, info := range infos {
95		if _, exists := r.ToolRegistry[info.Name]; exists {
96			continue
97		}
98		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
99		changed = true
100	}
101	if !changed {
102		return
103	}
104	// Re-render the system prompt with the merged tool list. Skill
105	// addendum is preserved by walking the existing append slice.
106	append_ := r.systemAppend
107	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
108		CWD:        r.CWD,
109		Tools:      toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
110		Custom:     r.systemCustom,
111		CustomSet:  r.systemCustomSet,
112		Append:     append_,
113		ZotDocsDir: filepath.Join(ZotHome(), "docs"),
114	})
115}
116
117// ExtensionToolSource is the slice of the extension manager that
118// MergeExtensionTools needs. Lives here as an interface so the
119// build package doesn't import packages/agent/extensions (which
120// imports core, which imports... avoid the cycle).
121type ExtensionToolSource interface {
122	Tools() []ExtensionToolInfo
123	NewExtensionTool(info ExtensionToolInfo) core.Tool
124}
125
126// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
127// ExtensionToolSource here without importing the extensions
128// package. The cli wires a tiny adapter to bridge them.
129type ExtensionToolInfo struct {
130	Extension   string
131	Name        string
132	Description string
133	Schema      []byte
134	Deferred    bool
135	Interactive bool
136}
137
138// toolSummariesFromRegistry rebuilds the system-prompt tool list
139// from a (possibly extended) registry, using cached descriptions for
140// the human-readable summary text.
141func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
142	out := make([]ToolSummary, 0, len(reg))
143	for name, t := range reg {
144		if d, ok := t.(interface{ Deferred() bool }); ok && d.Deferred() {
145			continue
146		}
147		desc := t.Description()
148		if d, ok := cached[name]; ok && d != "" {
149			desc = d
150		}
151		out = append(out, ToolSummary{Name: name, Description: desc})
152	}
153	return out
154}
155
156// defaultModelForProvider returns the model id zot prefers when the
157// caller didn't pick one. Mirrors the per-provider switch used at
158// multiple points in Resolve; centralised so the unknown-model
159// recovery path and the no-model-configured path can't drift.
160//
161// Returns the empty string for "ollama", which has no built-in
162// default — the caller is expected to special-case ollama and
163// error or use whatever the user passed.
164func defaultModelForProvider(prov string) string {
165	switch prov {
166	case "openai":
167		return "gpt-5"
168	case "openai-codex":
169		return "gpt-5.5"
170	case "openai-responses":
171		return "gpt-5"
172	case "kimi":
173		return "kimi-for-coding"
174	case "deepseek":
175		return "deepseek-v4-pro"
176	case "google":
177		return "gemini-2.5-pro"
178	case "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID:
179		return ""
180	case "moonshotai", "moonshotai-cn":
181		return "kimi-k2.6"
182	case "cerebras":
183		return "qwen-3-235b-a22b-instruct-2507"
184	case "groq":
185		return "llama-3.3-70b-versatile"
186	case "xai":
187		return "grok-4.5"
188	case "together":
189		return "Qwen/Qwen3-Coder-480B-A35B-Instruct"
190	case "huggingface":
191		return "moonshotai/Kimi-K2-Instruct"
192	case "openrouter":
193		return "anthropic/claude-sonnet-4.5"
194	case "gondola":
195		return "kimi-k3"
196	case "yolo-auto":
197		return "qwen3.8-flash"
198	case "mistral":
199		return "mistral-large-latest"
200	case "zai":
201		return "glm-4.7"
202	case "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp":
203		return "mimo-v2.5"
204	case "minimax", "minimax-cn":
205		return "MiniMax-M2.7"
206	case "fireworks":
207		return "accounts/fireworks/models/kimi-k2p6"
208	case "vercel-ai-gateway":
209		return "anthropic/claude-sonnet-4.5"
210	case "opencode":
211		return "claude-sonnet-4-5"
212	case "opencode-go":
213		return "kimi-k2.6"
214	case "amazon-bedrock":
215		return "anthropic.claude-sonnet-4-5-20250929-v1:0"
216	case "google-vertex":
217		return "gemini-2.5-pro"
218	case "azure-openai-responses":
219		return "gpt-5"
220	case "github-copilot":
221		return "claude-sonnet-5"
222	default:
223		// Custom providers: pick the first model from the catalog for
224		// that provider, or fall back to the global default.
225		if models := provider.ModelsForProvider(prov); len(models) > 0 {
226			return models[0].ID
227		}
228		return provider.DefaultModel.ID
229	}
230}
231
232// knownProviders is the set of provider ids zot recognises. Used by
233// Resolve to validate args.Provider, by extension-callers, and by the
234// auto-fallback logic that picks any logged-in provider when the user's
235// preferred one has no credentials.
236var knownProviders = []string{
237	"anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID,
238	"moonshotai", "moonshotai-cn",
239	"cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", "yolo-auto",
240	"mistral", "zai",
241	"xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
242	"minimax", "minimax-cn",
243	"fireworks", "vercel-ai-gateway",
244	"opencode", "opencode-go",
245	"amazon-bedrock", "google-vertex", "azure-openai-responses",
246	"github-copilot", "cloudflare-workers-ai", "cloudflare-ai-gateway",
247}
248
249func isKnownProvider(name string) bool {
250	for _, p := range knownProviders {
251		if p == name {
252			return true
253		}
254	}
255	_, ok := provider.CustomProviders()[name]
256	return ok
257}
258
259// isBuiltinProvider reports whether name is in the hardcoded
260// knownProviders list (not a user-defined custom provider).
261func isBuiltinProvider(name string) bool {
262	for _, p := range knownProviders {
263		if p == name {
264			return true
265		}
266	}
267	return false
268}
269
270// providerAliases maps common short / alternate provider names to the
271// canonical id in knownProviders. Users (and other agents) reach for
272// "bedrock" or "vertex" far more naturally than the fully-qualified
273// "amazon-bedrock" / "google-vertex"; without this mapping an alias is
274// treated as an unknown provider and Resolve silently falls back to
275// anthropic, producing a misleading "no credential for anthropic" error
276// after the user explicitly picked, say, bedrock.
277var providerAliases = map[string]string{
278	"bedrock":      "amazon-bedrock",
279	"aws-bedrock":  "amazon-bedrock",
280	"amazon":       "amazon-bedrock",
281	"vertex":       "google-vertex",
282	"gcp-vertex":   "google-vertex",
283	"gemini":       "google",
284	"googleai":     "google",
285	"google-ai":    "google",
286	"azure":        "azure-openai-responses",
287	"azure-openai": "azure-openai-responses",
288	"copilot":      "github-copilot",
289	"github":       "github-copilot",
290	"codex":        "openai-codex",
291	"moonshot":     "moonshotai",
292	"kimi-code":    "kimi",
293	"ai-gateway":   "vercel-ai-gateway",
294	"vercel":       "vercel-ai-gateway",
295	"cloudflare":   "cloudflare-workers-ai",
296	"workers-ai":   "cloudflare-workers-ai",
297	"hf":           "huggingface",
298	"llama":        provider.LlamaCPPProviderID,
299	"llamacpp":     provider.LlamaCPPProviderID,
300}
301
302// canonicalProvider normalises a user-supplied provider name: trims
303// surrounding whitespace, lower-cases it, and resolves any known alias
304// to its canonical id. Unknown names are returned trimmed/lower-cased
305// and unchanged so the existing unknown-provider handling still runs.
306func canonicalProvider(name string) string {
307	n := strings.ToLower(strings.TrimSpace(name))
308	if n == "" {
309		return n
310	}
311	if canon, ok := providerAliases[n]; ok {
312		return canon
313	}
314	return n
315}
316
317// Resolve merges args, config, and env into a Resolved set.
318//
319// Unlike the earlier version, Resolve NEVER returns an error for
320// missing credentials: the TUI can start without them and launch a
321// login flow. requireCred controls whether missing credentials are a
322// hard error (used by print/json modes).
323func Resolve(args Args, requireCred bool) (Resolved, error) {
324	cfg, _ := LoadConfig()
325
326	// User-requested provider (explicit > config > default).
327	// Normalise common aliases (e.g. "bedrock" -> "amazon-bedrock")
328	// before validation so an alias is never mistaken for an unknown
329	// provider and silently downgraded to anthropic.
330	argProvider := canonicalProvider(args.Provider)
331	provName := firstNonEmpty(argProvider, canonicalProvider(cfg.Provider), "anthropic")
332	if !isKnownProvider(provName) {
333		// Unknown provider (maybe removed or renamed). Fall back to
334		// the first provider that has credentials, or anthropic.
335		// Custom providers (from models.json) are already accepted
336		// by isKnownProvider, so we only reach here for truly unknown
337		// names.
338		provName = "anthropic"
339		if CredentialAvailable("openai") {
340			provName = "openai"
341		}
342		if CredentialAvailable("openai-codex") {
343			provName = "openai-codex"
344		}
345		if CredentialAvailable("kimi") {
346			provName = "kimi"
347		}
348		if CredentialAvailable("deepseek") {
349			provName = "deepseek"
350		}
351		if CredentialAvailable("anthropic") {
352			provName = "anthropic"
353		}
354		// Reset the saved config so this doesn't keep happening.
355		cfg.Provider = provName
356		cfg.Model = ""
357		_ = SaveConfig(cfg)
358	}
359
360	var (
361		cred      string
362		method    string
363		accountID string
364		credErr   error
365	)
366	if args.inheritedCredential != "" {
367		cred = args.inheritedCredential
368		method = args.inheritedAuthMethod
369		accountID = args.inheritedAccountID
370	} else if provName == "ollama" {
371		cred = firstNonEmpty(args.APIKey, "ollama")
372		method = "apikey"
373	} else {
374		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
375	}
376
377	// Persist --api-key for custom providers so subsequent runs don't
378	// need to pass it again.
379	if !isBuiltinProvider(provName) && args.APIKey != "" {
380		if store := AuthStoreFor(); store != nil {
381			_ = store.SetAPIKey(provName, args.APIKey)
382		}
383	}
384
385	// If the user did NOT explicitly pick a provider (neither via --provider
386	// nor by saving one in config.json) and the default one has no
387	// credentials, auto-fall-back to whichever provider is actually logged
388	// in. That way running plain `zot` after `/login` (any provider) never
389	// shows a "not logged in" banner.
390	//
391	// Critical: when the user HAS saved a provider in config.json (e.g.
392	// "deepseek" with "deepseek-v4-flash"), that choice is respected even
393	// if no credential is found — the tui will show the login dialog or
394	// surface a clear credential error rather than silently switching to
395	// a completely different provider and model.
396	// autoFellBack tracks whether the provider was changed by the
397	// auto-fallback loop (no explicit provider from CLI or config,
398	// and the default had no credentials). When false, the provider
399	// came from the user's explicit choice (--provider or config.json)
400	// and must be respected, including any model id that may belong
401	// to a different provider in the catalog — for example when using
402	// a gateway/router like OpenRouter with "deepseek/deepseek-v4-flash".
403	var autoFellBack bool
404	userPickedProvider := args.Provider != "" || cfg.Provider != ""
405	if credErr != nil && !userPickedProvider && provName != "ollama" {
406		autoFellBack = true
407		// Scan every known provider (not a hardcoded subset) so any
408		// env-based credential is discovered, e.g. an env-only
409		// amazon-bedrock setup (AWS_BEARER_TOKEN_BEDROCK / AWS_PROFILE /
410		// IAM keys) when no config.json pins the provider, such as after
411		// pointing ZOT_HOME at a fresh home dir. Iteration order of
412		// knownProviders defines fallback priority. Local providers without a
413		// default model are skipped because selecting either one here would
414		// fail before the user can choose a model.
415		for _, other := range knownProviders {
416			if other == provName || other == "ollama" || other == provider.LlamaCPPProviderID || other == provider.LMStudioProviderID {
417				continue
418			}
419			if !CredentialAvailable(other) {
420				continue
421			}
422			c, m, a, err := ResolveCredentialFull(other, args.APIKey)
423			provName = other
424			cred, method, accountID, credErr = c, m, a, err
425			break
426		}
427	}
428
429	model := firstNonEmpty(args.Model, cfg.Model)
430	if model == "" {
431		if provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID {
432			return Resolved{}, fmt.Errorf("%s requires --model or a model selected from its manager", provName)
433		}
434		model = defaultModelForProvider(provName)
435	}
436	// If the resolved model belongs to a different provider (e.g. config
437	// says gpt-5 but we auto-fell back to anthropic), pick that provider's
438	// default. This override only fires when the provider was auto-fallback-
439	// changed, NOT when the user explicitly configured the pair — gateway/
440	// router providers (openrouter, vercel-ai-gateway, etc.) can serve
441	// models from any provider in the catalog.
442	if autoFellBack && provName != "ollama" {
443		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
444			model = defaultModelForProvider(provName)
445		}
446	}
447	if provName == provider.LMStudioProviderID && LMStudioConfigured() {
448		// Restore transient metadata when resuming a local model after restart.
449		_ = RefreshLMStudioModels(context.Background())
450	}
451	if isDiscoverableCustomProvider(provName) {
452		if _, err := provider.FindModel(provName, model); err != nil {
453			// Same restore for discovery-enabled custom providers. Skipped
454			// when the model is already known to avoid a probe per launch.
455			_ = RefreshCustomProviderModels(context.Background())
456		}
457	}
458	resolvedModel, err := provider.FindModel(provName, model)
459	if err != nil && (provName == "ollama" || provName == provider.LlamaCPPProviderID || provName == provider.LMStudioProviderID) {
460		// Local providers are intentionally open-catalogue: any model id the
461		// configured server understands is valid, even if it is not cached.
462		resolvedModel = provider.Model{
463			Provider:      provName,
464			ID:            model,
465			DisplayName:   model,
466			ContextWindow: 128000,
467			MaxOutput:     16384,
468			BaseURL:       args.BaseURL,
469			Source:        provName,
470		}
471		if provName == provider.LMStudioProviderID {
472			resolvedModel.ContextWindow = 32768
473			resolvedModel.MaxOutput = 4096
474		}
475		err = nil
476	}
477	// Custom providers are open-catalogue like ollama: any model id the
478	// endpoint understands is valid. Use the provider-level base URL.
479	if err != nil {
480		if cfg, ok := provider.CustomProviders()[provName]; ok {
481			resolvedModel = provider.Model{
482				Provider:    provName,
483				ID:          model,
484				DisplayName: model,
485				BaseURL:     cfg.BaseURL,
486				Source:      "user",
487			}
488			err = nil
489		}
490	}
491	if cfg, ok := provider.CustomProviders()[provName]; ok && err == nil && resolvedModel.BaseURL == "" && cfg.BaseURL != "" {
492		// Fall back to the provider-level base URL when the model does
493		// not define its own endpoint.
494		resolvedModel.BaseURL = cfg.BaseURL
495	}
496	if err != nil {
497		// The model the user (or persisted config) asked for is no
498		// longer in the active catalogue — they probably removed it
499		// from their models.json or upgraded zot and the id changed.
500		// Refusing to launch is the wrong move: it strands the user
501		// with no way to even open the TUI and pick a new model.
502		// Fall back to the provider's default, warn on stderr, and,
503		// when the stale id came from the persisted config (not an
504		// explicit --model flag), repair the config so the warning
505		// doesn't repeat on every launch.
506
507		// Gateway providers can accept route-qualified ids that are not in
508		// zot's local catalog yet, for example OpenRouter's
509		// "deepseek/deepseek-v4-flash". Preserve only route-qualified ids;
510		// plain unknown values are likely typos and should still fall back.
511		if isGatewayProvider(provName) && isGatewayRoutedModelID(model) {
512			resolvedModel = provider.Model{
513				Provider:      provName,
514				ID:            model,
515				DisplayName:   model,
516				ContextWindow: 1000000,
517				MaxOutput:     64000,
518				BaseURL:       "",
519				Source:        "gateway",
520			}
521			err = nil
522			// Don't repair config — the routed model id may be valid upstream.
523		} else {
524			fallback := defaultModelForProvider(provName)
525			fm, ferr := provider.FindModel(provName, fallback)
526			if ferr != nil {
527				// Even the provider default is gone (catastrophic
528				// catalogue trim). Last resort: any model on this
529				// provider, then the global DefaultModel.
530				if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
531					fm = candidates[0]
532					ferr = nil
533				} else {
534					fm = provider.DefaultModel
535					ferr = nil
536				}
537			}
538			fmt.Fprintf(os.Stderr,
539				"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
540				model, fm.ID)
541			if args.Model == "" && cfg.Model == model {
542				cfg.Model = fm.ID
543				_ = SaveConfig(cfg)
544			}
545			resolvedModel = fm
546			model = fm.ID
547		}
548	}
549
550	// Discovered custom-provider models inherit the user's models.json
551	// endpoint, so they count as explicit for --insecure scoping too.
552	discoveredCustom := resolvedModel.Source == "live" && isDiscoverableCustomProvider(provName)
553	explicitBaseURL := args.BaseURL != "" || ((resolvedModel.Source == "user" || discoveredCustom) && resolvedModel.BaseURL != "")
554
555	// If the model defines a base URL (e.g. local ollama) and the
556	// user didn't pass --base-url, use the model's URL. For ollama,
557	// keep http://localhost:11434 as a fallback only after the model
558	// metadata has had a chance to provide a custom baseUrl.
559	if args.BaseURL == "" && resolvedModel.BaseURL != "" && (provName != provider.LMStudioProviderID || resolvedModel.Source == "user") {
560		// LM Studio discovery can retain an older snapshot on failure.
561		// Use its currently registered endpoint below, never a stale URL
562		// with the credentials for a newly registered server.
563		args.BaseURL = resolvedModel.BaseURL
564	}
565	if args.BaseURL == "" && provName == "ollama" {
566		args.BaseURL = "http://localhost:11434"
567	}
568	if args.BaseURL == "" && provName == provider.LMStudioProviderID {
569		root, _, configErr := resolveLMStudioConfig(context.Background(), apiKeyCommandExecute)
570		if configErr != nil {
571			return Resolved{}, configErr
572		}
573		if root == "" {
574			return Resolved{}, fmt.Errorf("LM Studio requires a server URL registered through /login or an explicit --base-url")
575		}
576		args.BaseURL = root + "/v1"
577	}
578	if args.BaseURL == "" && provName == provider.LlamaCPPProviderID {
579		managementURL, _, configErr := ResolveLlamaCPPConfig()
580		if configErr != nil {
581			return Resolved{}, configErr
582		}
583		var baseErr error
584		args.BaseURL, baseErr = provider.LlamaCPPInferenceURL(managementURL)
585		if baseErr != nil {
586			return Resolved{}, baseErr
587		}
588	}
589
590	// Insecure TLS is intentionally scoped to explicit custom endpoints.
591	// Built-in provider base URLs, auth calls, and model discovery keep normal
592	// certificate verification even when --insecure is present.
593	insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
594
595	// If the model has a base URL, credentials are optional (local
596	// models like ollama don't need real API keys).
597	if resolvedModel.BaseURL != "" && credErr != nil {
598		cred = "ollama"
599		credErr = nil
600		requireCred = false
601	}
602
603	if credErr != nil && requireCred {
604		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
605			credErr, envVarName(provName))
606	}
607
608	sandbox := tools.NewSandbox(args.CWD)
609	if cfg.JailByDefault != nil && *cfg.JailByDefault {
610		sandbox.Lock()
611	}
612	if args.PermissionSet != nil {
613		sandbox.SetPermissions(args.PermissionSet)
614	}
615	reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS)
616	if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
617		reg = withOpenRouterServerTools(reg)
618	}
619
620	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
621
622	// Skill discovery: scan project + global locations + built-in
623	// skills shipped with the binary. If any are found, register
624	// the on-demand `skill` loader tool plus a system-prompt
625	// manifest so the model knows what's available.
626	//
627	// --no-skill bypasses the entire mechanism: no manifest in the
628	// system prompt, no `skill` tool in the registry. Useful for a
629	// clean-room run with zero extra context biasing the model.
630	var (
631		discovered       []*skills.Skill
632		skillTool        *skills.Tool
633		skillAddendum    string
634		skillDiagnostics []string
635	)
636	if !args.NoSkill {
637		homeDir, _ := os.UserHomeDir()
638		var sources []skills.Source
639		if args.WithSkills {
640			for _, loc := range skills.SearchSources(ZotHome(), args.CWD, homeDir) {
641				sources = append(sources, loc)
642			}
643		}
644		if !args.NoExt || len(args.Exts) > 0 {
645			extSources, errs := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
646			sources = append(extSources, sources...)
647			for _, err := range errs {
648				skillDiagnostics = append(skillDiagnostics, err.Error())
649			}
650		}
651		var errs []error
652		discovered, errs = skills.DiscoverSources(sources, true)
653		for _, err := range errs {
654			skillDiagnostics = append(skillDiagnostics, err.Error())
655		}
656		if len(discovered) > 0 {
657			skillTool = skills.NewTool(discovered)
658			reg[skillTool.Name()] = skillTool
659			skillAddendum = skills.SystemPromptAddendum(discovered)
660		}
661	}
662	_ = skillTool
663
664	summaries := toolSummaries(reg, args)
665
666	var contextFiles []ContextFile
667	if !args.NoContextFiles {
668		contextFiles = loadAgentsContext(args.CWD, ZotHome())
669	}
670	append_ := append([]string(nil), args.AppendSystemPrompt...)
671	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
672		append_ = append(append_, agentsAddendum)
673	}
674	if skillAddendum != "" {
675		append_ = append(append_, skillAddendum)
676	}
677	if AutoSwarmEnabled() {
678		append_ = append(append_, AutoSwarmSystemAddendum)
679	}
680
681	// Custom system prompt resolution order:
682	//   1. --system-prompt flag (highest priority; ad-hoc per run)
683	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
684	//   3. built-in default (defaultIdentity + defaultGuidelines)
685	custom := args.SystemPrompt
686	customSet := args.SystemPromptSet || custom != ""
687	if !customSet {
688		custom = readUserSystemPrompt(ZotHome())
689		customSet = custom != ""
690	}
691
692	sys := BuildSystemPrompt(SystemPromptOpts{
693		CWD:        args.CWD,
694		Tools:      summaries,
695		Custom:     custom,
696		CustomSet:  customSet,
697		Append:     append_,
698		ZotDocsDir: docsDir,
699	})
700
701	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
702	temperature := args.Temperature
703	if temperature == nil {
704		temperature = cfg.Temperature
705	}
706
707	max := args.MaxSteps // 0 = unlimited
708
709	return Resolved{
710		Provider:         provName,
711		Model:            model,
712		Credential:       cred,
713		AuthMethod:       method,
714		AccountID:        accountID,
715		BaseURL:          args.BaseURL,
716		InsecureTLS:      insecureTLS,
717		CWD:              args.CWD,
718		Reasoning:        reasoning,
719		Temperature:      temperature,
720		ToolRegistry:     reg,
721		ToolSummary:      summaries,
722		SystemPrompt:     sys,
723		MaxSteps:         max,
724		MaxOutput:        resolvedModel.MaxOutput,
725		Sandbox:          sandbox,
726		SkillTool:        skillTool,
727		SkillDiagnostics: skillDiagnostics,
728		ContextFiles:     contextFiles,
729		systemAppend:     append_,
730		systemCustom:     custom,
731		systemCustomSet:  customSet,
732		toolDescriptions: descMapFromSummaries(summaries),
733	}, nil
734}
735
736// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
737// trimmed contents, or "" when the file is missing / unreadable /
738// empty. Errors are intentionally swallowed: the file is optional,
739// and any failure to read it should fall back to the built-in
740// default system prompt rather than crash the run.
741func readUserSystemPrompt(zotHome string) string {
742	if zotHome == "" {
743		return ""
744	}
745	path := filepath.Join(zotHome, "SYSTEM.md")
746	raw, err := os.ReadFile(path)
747	if err != nil {
748		return ""
749	}
750	return strings.TrimSpace(string(raw))
751}
752
753// loadAgentsContext loads optional AGENTS.md instruction files. No
754// default file is created or required: zot only includes files that
755// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
756// followed by project instructions from the filesystem root down to cwd.
757func loadAgentsContext(cwd, zotHome string) []ContextFile {
758	var files []ContextFile
759	seen := map[string]bool{}
760	add := func(path string) {
761		if path == "" {
762			return
763		}
764		abs, err := filepath.Abs(path)
765		if err == nil {
766			path = abs
767		}
768		if seen[path] {
769			return
770		}
771		raw, err := os.ReadFile(path)
772		if err != nil {
773			return
774		}
775		content := strings.TrimSpace(string(raw))
776		if content == "" {
777			return
778		}
779		seen[path] = true
780		files = append(files, ContextFile{Path: path, Content: content})
781	}
782	addFirstFromDir := func(dir string) {
783		if dir == "" {
784			return
785		}
786		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
787			path := filepath.Join(dir, name)
788			if _, err := os.Stat(path); err == nil {
789				add(path)
790				return
791			}
792		}
793	}
794
795	addFirstFromDir(zotHome)
796
797	if cwd != "" {
798		abs, err := filepath.Abs(cwd)
799		if err == nil {
800			cwd = abs
801		}
802		var dirs []string
803		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
804			dirs = append(dirs, dir)
805			parent := filepath.Dir(dir)
806			if parent == dir {
807				break
808			}
809		}
810		for i := len(dirs) - 1; i >= 0; i-- {
811			addFirstFromDir(dirs[i])
812		}
813	}
814
815	return files
816}
817
818func formatAgentsContext(files []ContextFile) string {
819	if len(files) == 0 {
820		return ""
821	}
822	var sb strings.Builder
823	sb.WriteString("Project context instructions loaded from AGENTS.md files. Follow them when working in this repository. Later files are more specific and may override earlier ones.\n")
824	for _, f := range files {
825		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
826	}
827	return strings.TrimSpace(sb.String())
828}
829
830// descMapFromSummaries indexes the human-readable descriptions for
831// the renderToolsSection rebuild path.
832func descMapFromSummaries(summaries []ToolSummary) map[string]string {
833	out := make(map[string]string, len(summaries))
834	for _, s := range summaries {
835		out[s.Name] = s.Description
836	}
837	return out
838}
839
840// NewClient returns a provider.Client for r, choosing the auth mode
841// based on r.AuthMethod. Panics if no credential is present; callers
842// must check HasCredential() first.
843func (r Resolved) NewClient() provider.Client {
844	if !r.HasCredential() {
845		panic("NewClient called without credential; check HasCredential first")
846	}
847	wrap := r.withHTTPClient
848	switch r.Provider {
849	case "ollama":
850		return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
851	case provider.LlamaCPPProviderID, provider.LMStudioProviderID:
852		credential := r.Credential
853		if credential == "local" {
854			credential = ""
855		}
856		return wrap(provider.NewOpenAI(credential, r.BaseURL))
857	case "kimi":
858		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
859		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
860		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
861		if r.AuthMethod == "oauth" {
862			return r.wrapWithRefresh(inner)
863		}
864		return inner
865	case "moonshotai":
866		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
867	case "moonshotai-cn":
868		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
869	case "deepseek":
870		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
871	case "openai":
872		return provider.NewModelRouter("openai",
873			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
874			map[string]provider.Client{
875				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
876			})
877	case "openai-codex":
878		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
879		return r.wrapWithRefresh(inner)
880	case "openai-responses":
881		// Public OpenAI Responses API (api.openai.com/v1/responses) via
882		// API key. Separate provider from `openai` (Chat Completions) and
883		// from `openai-codex` (ChatGPT subscription OAuth).
884		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
885	case "google":
886		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
887	case "cerebras":
888		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
889	case "groq":
890		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
891	case "xai":
892		inner := r.newXAIClient(r.Credential)
893		if r.AuthMethod == "oauth" {
894			return r.wrapWithRefresh(inner)
895		}
896		return inner
897	case "together":
898		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
899	case "huggingface":
900		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
901	case "openrouter":
902		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
903	case "gondola":
904		return wrap(provider.NewGondola(r.Credential, r.BaseURL))
905	case "yolo-auto":
906		return wrap(provider.NewYoloAuto(r.Credential, r.BaseURL))
907	case "zai":
908		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
909	case "xiaomi":
910		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
911	case "xiaomi-token-plan-ams":
912		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
913	case "xiaomi-token-plan-cn":
914		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
915	case "xiaomi-token-plan-sgp":
916		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
917	case "opencode":
918		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
919	case "opencode-go":
920		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
921	case "minimax":
922		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
923	case "minimax-cn":
924		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
925	case "fireworks":
926		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
927	case "vercel-ai-gateway":
928		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
929	case "mistral":
930		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
931	case "amazon-bedrock":
932		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
933	case "google-vertex":
934		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
935	case "azure-openai-responses":
936		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
937	case "github-copilot":
938		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
939	case "cloudflare-workers-ai":
940		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
941	case "cloudflare-ai-gateway":
942		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
943	default:
944		// Custom providers: choose wire format from the models.json api field.
945		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
946			switch cfg.API {
947			case provider.APIResponses:
948				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
949			case "anthropic":
950				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
951			default: // "openai"
952				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
953			}
954		}
955		if r.AuthMethod == "oauth" {
956			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
957			return r.wrapWithRefresh(inner)
958		}
959		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
960	}
961}
962
963func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
964	if !r.InsecureTLS {
965		return c
966	}
967	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
968}
969
970func (r Resolved) newXAIClient(token string) provider.Client {
971	return provider.NewModelRouter("xai",
972		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
973		map[string]provider.Client{
974			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
975		})
976}
977
978// wrapWithRefresh wraps an OAuth client so the access token is
979// refreshed automatically before each API call. Without this, long
980// sessions (hours) silently fail when the 1-hour token expires.
981func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
982	provName := r.Provider
983	tokenProvider := provName
984	if provName == "openai-codex" {
985		tokenProvider = "openai"
986	}
987	baseURL := r.BaseURL
988	accountID := r.AccountID
989
990	refreshFn := func(ctx context.Context) (string, error) {
991		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
992		if err != nil {
993			return "", err
994		}
995		return tok.AccessToken, nil
996	}
997
998	factory := func(token string) provider.Client {
999		switch provName {
1000		case "openai-codex":
1001			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1002		case "kimi":
1003			// anthropic-messages on api.kimi.com/coding.
1004			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1005		case "xai":
1006			return r.newXAIClient(token)
1007		default:
1008			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1009		}
1010	}
1011
1012	return provider.NewRefreshingClient(inner, refreshFn, factory)
1013}
1014
1015// UseSandbox replaces the sandbox pointer that every tool in r's
1016// registry references. Used to keep the /jail state stable across
1017// agent rebuilds (e.g. /login, /model switching providers).
1018func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1019	if s == nil || r == nil {
1020		return
1021	}
1022	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1023		s.Permissions = r.Sandbox.Permissions
1024	}
1025	r.Sandbox = s
1026	for name, t := range r.ToolRegistry {
1027		switch v := t.(type) {
1028		case *tools.ReadTool:
1029			v.Sandbox = s
1030		case *tools.WriteTool:
1031			v.Sandbox = s
1032		case *tools.EditTool:
1033			v.Sandbox = s
1034		case *tools.BashTool:
1035			v.Sandbox = s
1036		case *tools.PowerShellTool:
1037			v.Sandbox = s
1038		case *tools.GlobTool:
1039			v.Sandbox = s
1040		}
1041		_ = name
1042	}
1043}
1044
1045// NewAgent constructs a core.Agent from r. Requires a credential.
1046func (r Resolved) NewAgent() *core.Agent {
1047	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1048	a.MaxSteps = r.MaxSteps
1049	a.MaxTokens = r.MaxOutput
1050	a.Reasoning = r.Reasoning
1051	a.Temperature = r.Temperature
1052	if hasOpenRouterServerTools(r.ToolRegistry) {
1053		a.MaxToolCalls = openRouterServerToolCallLimit()
1054	}
1055	return a
1056}
1057
1058// openRouterServerToolCallLimit returns the configured call limit from
1059// config.json, falling back to the built-in default (4).
1060func openRouterServerToolCallLimit() int {
1061	cfg, err := LoadConfig()
1062	if err != nil {
1063		return tools.OpenRouterServerToolCallLimit
1064	}
1065	if cfg.OpenRouterServerToolCallLimit != nil && *cfg.OpenRouterServerToolCallLimit > 0 {
1066		return *cfg.OpenRouterServerToolCallLimit
1067	}
1068	return tools.OpenRouterServerToolCallLimit
1069}
1070
1071// OpenRouterServerToolsEnabled reports the persisted preference.
1072// nil/missing and unreadable configuration fail closed to disabled.
1073func OpenRouterServerToolsEnabled() bool {
1074	cfg, err := LoadConfig()
1075	if err != nil {
1076		return false
1077	}
1078	return cfg.OpenRouterServerToolsEnabled != nil && *cfg.OpenRouterServerToolsEnabled
1079}
1080
1081func isOpenRouterPresetModel(model string) bool {
1082	return strings.Contains(strings.ToLower(model), "@preset/")
1083}
1084
1085func hasOpenRouterServerTools(reg core.Registry) bool {
1086	for name := range reg {
1087		if tools.IsOpenRouterServerTool(name) {
1088			return true
1089		}
1090	}
1091	return false
1092}
1093
1094func withOpenRouterServerTools(reg core.Registry) core.Registry {
1095	out := core.Registry{}
1096	for name, t := range reg {
1097		out[name] = t
1098	}
1099	for _, t := range tools.OpenRouterServerTools() {
1100		out[t.Name()] = t
1101	}
1102	return out
1103}
1104
1105func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry {
1106	reg := buildToolRegistry(args, cwd, sandbox)
1107	if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled {
1108		reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1109	}
1110	return reg
1111}
1112
1113func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
1114	if args.NoTools {
1115		return core.Registry{}
1116	}
1117	all := map[string]core.Tool{
1118		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1119		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
1120		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
1121		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1122		"glob":  &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1123	}
1124	reg := core.Registry{}
1125	if len(args.Tools) == 0 {
1126		for _, t := range all {
1127			reg[t.Name()] = t
1128		}
1129		return reg
1130	}
1131	// PowerShell is explicitly selected, never added to the default tool set.
1132	all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1133	for _, name := range args.Tools {
1134		if t, ok := all[name]; ok {
1135			reg[name] = t
1136		}
1137	}
1138	return reg
1139}
1140
1141func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1142	order := []string{"read", "write", "edit", "bash", "powershell", "glob"}
1143	var out []ToolSummary
1144	for _, name := range order {
1145		if t, ok := reg[name]; ok {
1146			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1147		}
1148	}
1149	return out
1150}
1151
1152func firstNonEmpty(vals ...string) string {
1153	for _, v := range vals {
1154		if v != "" {
1155			return v
1156		}
1157	}
1158	return ""
1159}
1160
1161func kimiCodeHeaders() map[string]string {
1162	host, _ := os.Hostname()
1163	if host == "" {
1164		host = "unknown"
1165	}
1166	deviceID := ""
1167	if home, err := os.UserHomeDir(); err == nil {
1168		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1169			deviceID = strings.TrimSpace(string(b))
1170		}
1171	}
1172	if deviceID == "" {
1173		deviceID = "zot"
1174	}
1175	return map[string]string{
1176		"User-Agent":         "KimiCLI/1.41.0",
1177		"X-Msh-Platform":     "kimi_cli",
1178		"X-Msh-Version":      "1.41.0",
1179		"X-Msh-Device-Name":  host,
1180		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1181		"X-Msh-Os-Version":   runtime.GOOS,
1182		"X-Msh-Device-Id":    deviceID,
1183	}
1184}
1185
1186func envVarName(provider string) string {
1187	switch provider {
1188	case "openai", "openai-codex", "openai-responses":
1189		return "OPENAI"
1190	case "kimi":
1191		return "KIMI"
1192	case "deepseek":
1193		return "DEEPSEEK"
1194	case "google":
1195		return "GEMINI"
1196	case "ollama":
1197		return "OLLAMA"
1198	case "llama.cpp":
1199		return "LLAMA"
1200	case "moonshotai", "moonshotai-cn":
1201		return "MOONSHOT"
1202	case "groq":
1203		return "GROQ"
1204	case "xai":
1205		return "XAI"
1206	case "cerebras":
1207		return "CEREBRAS"
1208	case "together":
1209		return "TOGETHER"
1210	case "huggingface":
1211		return "HF"
1212	case "openrouter":
1213		return "OPENROUTER"
1214	case "gondola":
1215		return "GONDOLA"
1216	case "yolo-auto":
1217		return "YOLO_AUTO"
1218	case "mistral":
1219		return "MISTRAL"
1220	case "zai":
1221		return "ZAI"
1222	case "xiaomi":
1223		return "XIAOMI"
1224	case "xiaomi-token-plan-ams":
1225		return "XIAOMI_TOKEN_PLAN_AMS"
1226	case "xiaomi-token-plan-cn":
1227		return "XIAOMI_TOKEN_PLAN_CN"
1228	case "xiaomi-token-plan-sgp":
1229		return "XIAOMI_TOKEN_PLAN_SGP"
1230	case "minimax":
1231		return "MINIMAX"
1232	case "minimax-cn":
1233		return "MINIMAX_CN"
1234	case "fireworks":
1235		return "FIREWORKS"
1236	case "vercel-ai-gateway":
1237		return "AI_GATEWAY"
1238	case "opencode", "opencode-go":
1239		return "OPENCODE"
1240	case "github-copilot":
1241		return "COPILOT_GITHUB_TOKEN"
1242	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1243		return "CLOUDFLARE"
1244	case "amazon-bedrock":
1245		return "AWS"
1246	case "google-vertex":
1247		return "GOOGLE_CLOUD"
1248	case "azure-openai-responses":
1249		return "AZURE_OPENAI"
1250	default:
1251		return "ANTHROPIC"
1252	}
1253}

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